Deno deno_crypto 扩展:seed 注入与密钥驻留的路径怎么走
【免费下载链接】denoA modern runtime for JavaScript and TypeScript.项目地址: https://gitcode.com/GitHub_Trending/de/deno
deno_crypto扩展负责实现 W3C Web Cryptography API:你在 JS 里写的crypto.subtle.sign()最终落在哪段 Rust 上。这个 crate 的源码演进之后,JS 侧被刻意做薄——所有 WebCrypto 方法体都下沉到 Rust 侧、挂在 V8 垃圾回收管理的 cppgc 包裹类上(cppgc 是 V8 的 C++ 对象回收机制,Rust 结构体实现GarbageCollected后即归 V8 GC 管理)。下面沿着两条注入路径走:初始化 seed 如何改写随机数行为,密钥材料为什么住在 Rust 侧的 GC 对象里而不是 JSWeakMap。
WebCrypto 方法怎么变成 V8 里的原生方法?
lib.rs 的扩展声明里,现在只剩两个独立 op:
deno_core::extension!(deno_crypto, deps = [ deno_webidl, deno_web ], ops = [ crypto::op_crypto_random_uuid_batch, op_crypto_is_seeded, ], objects = [ crypto::Crypto, subtle_crypto::SubtleCrypto, crypto_key::CryptoKey, ], lazy_loaded_js = [ "00_crypto.js" ], options = { maybe_seed: Option<u64>, }, // state 闭包:有 seed 则 state.put(StdRng::seed_from_u64(seed)) );deno_core::extension!宏把 ops、cppgc 类、JS 脚本和 options 一次性声明给 deno_core。objects列表是关键:crypto.subtle.sign这类调用最终命中 Rust 结构体上的#[op2]方法,而不是先落到 JS 函数再跨 op 边界。这个文件里你关注两处就够:extension! 宏声明(114 行起)和CryptoError枚举(136 行起),后者是后面错误映射的主角。
00_crypto.js 现在只剩四类簿记工作,文件头部注释自己承认了 "intentionally thin":
- 原型装饰:给三个原型挂
Deno.privateCustomInspect符号,让Deno.inspect(crypto.subtle)打印SubtleCrypto {}而不是内部 cppgc 形状; - 惰性铸造单例:cppgc 堆在快照构建期没有附着到 V8 isolate,所以
globalThis.crypto/crypto.subtle单例必须延迟到运行时第一次读取时,经Crypto.create(getSubtleSingleton())/SubtleCrypto.create()铸造;铸造时顺手把 WebIDL brand 和 Node 的kKeyObject符号交给 Rust,之后 Rust 侧新铸的CryptoKey都自带这两个 brand; - structured-clone 复活回调:
core.registerCloneableResource("CryptoKey", ...)让CryptoKey能跨 Worker 结构化克隆,克隆数据经CryptoKey.fromCloneData重铸 cppgc 实例; Function.length修正:见后面"同步 throw"一节。
new Crypto()/new SubtleCrypto()在 Rust 侧直接返回IllegalConstructor错误——规范里这两个接口本来就不允许new。
密钥材料为什么从 JS WeakMap 迁到 Rust 的 cppgc 对象?
旧实现里,每个CryptoKey的密钥字节存在 00_crypto.js 的一个 JSWeakMap(历史上叫KEY_STORE)里,每次 sign/encrypt/derive 都要把密钥字节序列化、跨过 JS/Rust 边界传进 op。现在密钥字节住在 key_store.rs 的CryptoKeyHandle里,JS 侧只存 handle。这个文件总共 48 行,看结构体定义和它的unsafe impl GarbageCollected就够了——trace是空实现,因为 handle 不持有指向 V8 堆的引用。
迁移的收益是双向的。性能上,lib.rs 里KeyData的注释写得很直白:"Previously the key bytes were serialized and passed from JavaScript on every operation." 密钥字节从此只在 import/generate 时过一次边界,之后的操作走 Rust 内部引用。生命周期上,handle 是 V8 GC 对象,CryptoKey被回收时密钥随之自动释放,不需要FinalizationRegistry或手动簿记。
素材的形态
shared.rs 的RawKeyData枚举表达密钥素材的全部形态,这个文件里你关注RawKeyData和SharedError两个定义:
Secret/Private/Public:带用途标签的字节,分别对应 HMAC 密钥、PKCS8 私钥、SPKI 公钥;Raw:原样存放的字节(Ed25519/X25519/X448/ML-KEM 公钥),不携带 secret/private/public 标签;SeededPrivate { seed, private_key }:FIPS 203/204 算法(ML-KEM 解封装密钥、ML-DSA 签名密钥)的复合素材。private_key是展开后的完整密钥,seed是短种子;从展开后的原始字节导入时seed为None,此时导出 raw-seed/jwk/pkcs8 格式会被正确拒绝。
派生密钥到KeyData(sign/verify/derive 消费的形态)时,SeededPrivate分支被标成unreachable!()——复合素材从不直接交给这些操作。
seed 如何从 Option 走到 randomUUID 的两条分派路径?
扩展宏的options.maybe_seed字段接到三个注入点:runtime/worker.rs(636 行附近,deno_crypto::deno_crypto::args(options.seed))、runtime/web_worker.rs(init(options.seed))、runtime/snapshot.rs(lazy_init())。seed 的语义是确定性伪随机数流,主要服务测试与快照场景的可复现性。
deno_crypto::args(seed) // runtime/worker.rs,随扩展参数注入 │ ▼ state 闭包:state.put(StdRng::seed_from_u64(seed)) // 只存一个 RNG │ ▼ JS:getCryptoSingleton() 里 op_crypto_is_seeded() (查 OpState 里有没有 StdRng)→ usesSeededRng 标志 │ ├── 有 seed:randomUUID 每次走原生 cppgc 方法,逐条消耗 16 字节 └── 无 seed:op_crypto_random_uuid_batch 一次取 128 条,JS 切片JS 侧,getCryptoSingleton()铸造单例时调用op_crypto_is_seeded()记下usesSeededRng标志(它只检查OpState里是否放了StdRng)。randomUUID的实现:
function randomUUID() { if (this !== cryptoSingleton || usesSeededRng) { return FunctionPrototypeCall(cppgcRandomUUID, this); } if (uuidBatch === UUID_BATCH_SIZE) { uuidBatchData = op_crypto_random_uuid_batch(); // 一次取回 128 条 uuidBatch = 0; } const start = uuidBatch++ * UUID_STRING_BYTES; return StringPrototypeSlice(uuidBatchData, start, start + UUID_STRING_BYTES); }00_crypto.js 里你关注randomUUID函数和getCryptoSingleton两处,其余都是装饰代码。批量路径背后是 crypto.rs 的op_crypto_random_uuid_batch:一次从thread_rng填 2048 字节,逐 16 字节组调fast_uuid_v4_bytes,拼成 4608 字节的字符串整体返回 JS,后续 127 次调用只做StringPrototypeSlice,不再跨边界。
两条 UUID 路径的分派点
fast_uuid_v4_bytes(lib.rs 底部)在 16 字节上就地设置版本位与变体位(bytes[6] = (bytes[6] & 0x0f) | 0x40等),再用HEX_CHARS查表直接拼出 36 字符字符串,绕开格式化开销;同文件里test_fast_uuid_v4_correctness与uuidcrate 逐字节对拍,保证这个手写转换不出错。
seeded 运行时为什么放弃批量、走原生方法?因为批量路径会一次消耗 2048 字节熵,而 seed 场景下每次randomUUID应当恰好消耗 16 字节、保持严格的 RNG 调用顺序——测试断言依赖这个顺序可复现。JS 侧get_random_values也走同一个分派:OpState里有StdRng就用它,否则回落到thread_rng。
一次 sign() 调用在 Rust 侧走了多远?
subtle_crypto.rs 是所有SubtleCrypto方法的 cppgc 入口,这个文件里你关注sign/digest两个方法体就够——它们代表全部 20 多个方法的统一形态:
#[required(3)] #[arraybuffer] async fn sign( &self, #[webidl] algorithm: SubtleSignParams, #[webidl] key: SubtleKey, #[webidl] data: BufferSource, ) -> Result<Vec<u8>, CryptoError> { spawn_blocking(move || run_sign(algorithm, key, data.0)).await? }调用链分三段:#[webidl]参数由各自的WebIdlConverter在 V8 栈上同步解析——算法字典解析成SubtleSignParams,SubtleKey把CryptoKey的槽位(algorithm.name/hash/usages/type 以及底层CryptoKeyHandle的密钥字节)拍成独立快照;然后spawn_blocking把纯 Rust 的run_sign丢进 tokio 阻塞线程池,在 V8 栈外执行。密钥快照在过spawn_blocking之前就是 Rust 拥有的Vec<u8>/Box<[u8]>,不再触碰 V8 堆。
subtle_sign.rs 的run()先校验算法名与 key 是否匹配、sign用途是否存在,再把SubtleKey转成KeyData调 lib.rs 的sign_key_sync。后者按Algorithm变体分派:RSASSA-PKCS1-v1_5 从 PKCS#1 DER 解私钥后按 hash 构造签名,缺hash报MissingArgumentHash;RSA-PSS 额外要求salt_length,用OsRng的随机盐签名;ECDSA 从 PKCS#8 解私钥、先算 prehash 再sign_prehash,产出 rawr||s;HMAC 的 SHA-3 变体走tiny-keccak,其余走aws_lc_rs。verify_key_sync结构对称,但多一条路径:KeyType::Private的 ECDSA 验证会先从 PKCS#8 私钥推导VerifyingKey——这是规范允许的"用私钥对象验证"行为;验证失败统一返回false而非抛错。
一个容易漏掉的曲线细节
P-521 的域大小是 66 字节,bits2field要求字段值至少 33 字节,所以 prehash 不足 33 字节时要左补零:
// P-521 field size is 66 bytes; bits2field requires at least // half that (33 bytes). Left-pad shorter hashes to meet the minimum. let prehash = if prehash.len() < 33 { let mut padded = vec![0u8; 33 - prehash.len()]; padded.extend_from_slice(&prehash); padded } else { prehash };这段在sign_key_sync和verify_key_sync里各出现一次。P-521 配 SHA-1(20 字节)时不补零,签名与验签就会互不相认——曲线相关的这种边界,是测试里最容易漏的一类。
WPT 是怎么把边界行为钉死的?
WPT(Web Platform Tests,W3C 的 Web 标准兼容性测试集)把边界行为钉得很死,converter 层不能直接拒绝未知算法名是其中一条。WebIdlError是硬编码TypeError的类型,而 WPTdigest.https.any.html的子测试断言错误名必须是NotSupportedError——所以DigestAlgorithm的 converter 把未登记的名字保留为Unknown(name)变体,推迟到run()抛出规范要求的DOMException。sign 侧同理:subtle_sign.rs 里read_required_hash的注释明确说不要在这里校验 hash 名,因为 WPT "bad hash name" 用例断言err.name === "NotSupportedError";未知名进入SubtleSignParams::Unknown(name),由run()末尾统一抛NotSupportedError。XOF 参数字典(outputLength、domainSeparation)的校验也推迟到 run 时,违规统一报InvalidXofParameters(OperationError);digest.rs 里 SHA-1/SHA-2/SHA-3 之外还登记了 cSHAKE/TurboSHAKE/KangarooTwelve 七个 XOF 算法,read_optional_u8负责读取可选的 u8 参数字典成员,范围不合规时抛 OperationError。
同步 throw 与 rejected promise
WebCrypto 规范规定每个SubtleCrypto方法返回 Promise,但 op2 dispatcher 在 async 主体运行之前就同步调用WebIdlConverter,converter 层的 throw 会直接同步到达调用点,而 WPT 的promise_rejects_domharness 期望 rejected Promise。00_crypto.js 用makeAsyncForwarder把所有方法包一层 async,让同步错误也走 Promise rejection,同时把Function.length修正为规范要求的必需参数个数(unwrapKey是 7、deriveKey是 5、digest是 2)。deriveBits的转发器还有第三个约束:op2 宏没法声明可选参数同时保持最少参数检查,直接用#[required(2)]会把Function.length压成 2,且第三个用户参数会在到达 Rust 前被静默丢弃——所以 JS 侧手写一个三参转发器,显式透传全部参数。
如果你要继续往下读源码,三个入口各有分工:ext/crypto/subtle_crypto.rs 是所有SubtleCrypto方法的 cppgc 入口,每个方法都是"WebIDL 参数转换 →spawn_blocking→ 纯 Rustrun_*"的固定五件套,读懂一个就能推全部;ext/crypto/digest.rs 是 converter 加推迟错误模式的完整样本,含 XOF 注册表与参数校验,适合对照 WPT 用例看 Deno 如何在 Rust 里复刻 JS 时代的每个边界行为;runtime/worker.rs(636 行附近)是 seed 从WorkerOptions.seed透传到扩展 options 的唯一现场,配合 tests/unit/webcrypto_test.ts 能验证前面两条路径的行为边界。
【免费下载链接】denoA modern runtime for JavaScript and TypeScript.项目地址: https://gitcode.com/GitHub_Trending/de/deno
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考