指南

签名包

签名包(.nshpkg)让宿主应用能够运行并非随应用一起发布的 nsh 代码:可以是应用自身 nsh 页面的更新,也可以是第三方 小程序。nsh publish 负责构建并签名应用;宿主应用在运行任何代码之前,会依次验证签名、证书链、吊销状态和构建号。

签名包:离线的根密钥为发布密钥签发证书,限定应用 ID;nsh publish 用发布密钥签名包;在设备上,NshTrust 只信任根密钥和最新的吊销列表,依次检查证书、吊销、签名、应用 ID、文件哈希和构建号,然后运行模块或拒绝它。 签名包:离线的根密钥为发布密钥签发证书,限定应用 ID;nsh publish 用发布密钥签名包;在设备上,NshTrust 只信任根密钥和最新的吊销列表,依次检查证书、吊销、签名、应用 ID、文件哈希和构建号,然后运行模块或拒绝它。

#为什么要签名

宿主应用运行的 JS 能访问应用授予的一切,因此它必须来自应用信任的一方。另一个原因是: nsh 以 QuickJS 字节码的形式发布应用,加载比源码更快,但 QuickJS 不会校验字节码,精心构造的字节码可以破坏内存。所以 nsh 只从三个地方加载字节码:应用自身(经系统签名)的资源、开发者所连接的开发服务器,以及通过验证的包。其他来源一律 只能是 JS 源码。

#密钥:两个层级

  • 根密钥(root key) 离线保存。宿主应用信任它的公钥;宿主应用可以同时信任多个根,轮换根密钥就是靠这一点实现的。
  • 根密钥 为发布密钥(release key)签发证书:证书中写明发布密钥、它可以为哪些 app id 签名,以及有效时间段。
  • 包由发布密钥签名,并附带该密钥的证书。因此,发布密钥可以轮换(或吊销),而无需更新只认识根密钥的宿主应用。

每个密钥都有一个由公钥派生的 id(其 SHA-256 的前 8 个字节,十六进制表示),每条命令都会打印出来。

#创建密钥

nsh keygen keys/root
nsh keygen keys/release
key 4e0b637f5e10f6a6: keys/root.secret (keep it secret), keys/root.pub
key 3428d7136788c9ee: keys/release.secret (keep it secret), keys/release.pub

每个密钥由两个文件组成:<name>.secret(仅所有者可读)和 <name>.pub,内容都是一行 base64。nsh keygen 绝不会 覆盖已有的密钥。

#为发布密钥签发证书

nsh certify --root keys/root.secret --key keys/release.pub --apps 'com.example.*' -o keys/release.cert
keys/release.cert: key 3428d7136788c9ee certified by root 4e0b637f5e10f6a6 for com.example.* until 365 days from now
参数说明
--root <file>根密钥的私钥。
--key <file>发布密钥的公钥。
--apps <pattern>它可以为哪些 app id 签名:*(任意)、com.example.*(com.example. 下的 id)或某个确切的 id。可重复指定。
--days <n>从现在起可以签名的天数。默认 365。
-o, --out <file>要写入的证书文件。

证书是一个很小的 JSON 文件,不需要保密。

#发布包

nsh publish App.tsx --key keys/release.secret --cert keys/release.cert
out/com.example.counter-2.nshpkg: com.example.counter 1.0.0 (build 2), 0 assets, 12 KB, signed by 3428d7136788c9ee

nsh publish 以 release 模式构建应用并签名。app id、名称、版本和构建号取自入口文件旁的 nsh.json,可以用下列参数覆盖:

参数说明
--key <file>发布密钥的私钥。
--cert <file>它的证书。
-o, --out <file>要写入的包。默认 out/<id>-<build>.nshpkg。
--id <id>app id,例如 com.example.app。
--app-version <version>版本,例如 1.2.0。
--build <n>构建号。必填(在此处或 nsh.json 中指定);每次发布都必须递增。

凡是设备会拒绝的包,nsh publish 也拒绝签名:证书必须覆盖该 app id 和当前时间。

Error: the release key's certificate does not cover this package: it does not cover app `com.example.counter` (only com.other.app)

#包里有什么

包是单个文件:先是一个 JSON 头(app id、名称、版本、构建号、声明的能力、字节码版本、签名时间、发布密钥的证书,以及 一张列出各文件及其 SHA-256 的文件表),然后是各个文件,最后是发布密钥对前面所有内容的 Ed25519 签名。其中的文件包括:

  • 应用的 JS 源码(始终包含);
  • 它的字节码,仅当设备上运行时的字节码版本与之相同时才会使用(否则运行源码,因此包在更新的运行时上仍然可用);
  • 它的 source map;
  • 它导入的资源(图片、字体)。

包的标识(用于吊销)是整个文件的 SHA-256。

#验证包

nsh verify 检查包的方式,与信任指定根密钥的宿主应用完全一致:

nsh verify out/com.example.counter-2.nshpkg --root keys/root.pub
valid: com.example.counter 1.0.0 (build 2), release key 3428d7136788c9ee, sha256 f40565358c13fafce369792e41125fea7f93fe87dd1bc24fd575dabc09b34fd4, 0 assets, bytecode for this runtime
参数说明
--root <file>受信任的根公钥。可重复指定。不指定时,只检查包自身的签名。
--revocations <file>先应用的吊销列表。

设备按以下顺序检查:

  1. 证书由受信任的根签名;
  2. 发布密钥未被吊销;
  3. 包由发布密钥签名;
  4. 证书覆盖该 app id 和签名时间;
  5. 包所需的最低运行时版本;
  6. 文件表与数据完全吻合,且每个文件都与其哈希一致;
  7. 包本身未被吊销;
  8. 其构建号不低于此前已接受的该应用的最高构建号(回滚保护)。

被拒绝的包会抛出错误,指明哪一项检查失败;正在运行的应用会继续运行。

#吊销密钥和包

根密钥可以签署 吊销列表:列出不得再加载的发布密钥和包。

# The first list.
nsh revoke --root keys/root.secret --sequence 1 \
  --package f40565358c13fafce369792e41125fea7f93fe87dd1bc24fd575dabc09b34fd4 -o revocations.json

# Later: keep everything in the last list and add a release key (sequence becomes 2).
nsh revoke --root keys/root.secret --extend revocations.json --key 3428d7136788c9ee -o revocations-2.json
参数说明
--root <file>根密钥的私钥。
--extend <file>保留此列表(最近发布的那份)中的条目。
--sequence <n>列表的序号,必须大于此前发布过的所有序号。使用 --extend 时,默认取该列表的序号加一。
--key <id>要吊销的发布密钥 id。可重复指定。
--package <sha256>要吊销的包的 SHA-256(即 nsh verify 打印的值)。可重复指定。
-o, --out <file>要写入的列表。

信任存储只接受序号比当前所持列表更高的列表,因此无法通过重放旧列表来撤销吊销。请把最新的列表发布到应用获取它的地方, 并在打开包之前用 updateRevocations 应用它。

#回滚保护

宿主应用接受一个包后,其信任存储会记录该 app id 的构建号,此后拒绝更旧的构建。即使攻击者拿到了一个存在已知漏洞的 旧签名包,也无法让应用运行它。这条记录保存在信任存储的状态目录中(nsh-trust.json),重启后依然有效。

#在宿主应用中加载包

验证需要对整个包做哈希并解出其资源(每个包只解一次,放在信任存储的目录下),因此应在 UI 线程之外执行;运行结果则在 UI 线程上进行。

iOS(Swift):

let trust = try NshTrust(stateDirectory: applicationSupport.appendingPathComponent("nsh"))
try trust.addRoot(rootPublicKey)                // the contents of root.pub
try trust.updateRevocations(latestListJSON)     // when you have one

let info = try await app.loadPackage(at: downloaded, trust: trust)   // verify off the main thread, then run

Android(Java):

NshTrust trust = new NshTrust(new File(getFilesDir(), "nsh"));
trust.addRoot(rootPublicKey);
trust.updateRevocations(latestListJson);

NshPackage pkg = trust.openPackage(downloaded);   // off the UI thread; throws NshPackage.RefusedException
runOnUiThread(() -> nsh.runPackage(pkg));

包声明的能力随包一起传递:包只能做它声明的事,并且还要在宿主应用授予的范围之内。

完整 API:嵌入 iOS 应用、 嵌入 Android 应用。

#在开发中试用包

nsh run 安装的开发宿主可以像嵌入式应用那样运行包。

在 iOS 上,宿主可执行文件接受 --package <file> 以及一个或多个 --root <root.pub>。在 nsh run 将宿主安装到模拟器 (--target ios)之后:

xcrun simctl launch booted com.longbridge.nsh.host \
  --package "$PWD/out/com.example.counter-2.nshpkg" --root "$PWD/keys/root.pub"

在 Android 上,开发宿主(DevActivity,由 nsh run --target android 安装)接受 extras package(设备上的路径)和 root(base64 公钥,以逗号分隔):

adb push out/com.example.counter-2.nshpkg /sdcard/Android/data/com.longbridge.nsh.host/files/app.nshpkg
adb shell am start -S -n com.longbridge.nsh.host/com.longbridge.nsh.DevActivity \
  --es package /sdcard/Android/data/com.longbridge.nsh.host/files/app.nshpkg \
  --es root "$(cat keys/root.pub)"

它们的信任状态保存在临时目录(iOS)或缓存目录(Android)中,因此比已接受版本更旧的构建在这里同样会被拒绝。

#发布检查清单

  1. 根密钥离线保存;按团队或按应用系列签发发布密钥,有效期不超过一年。
  2. 每次发布都提高 nsh.json 中的 build;没有构建号的包 nsh publish 会拒绝。
  3. 上传前对包运行 nsh verify --root root.pub。
  4. 将 root.pub 内置在宿主应用中;吊销列表单独分发,这样无需更新应用就能吊销。
  5. 如果发布密钥泄露,吊销它(nsh revoke --extend … --key <id>),签发新的发布密钥并重新发布。

#另请参阅