Deno 把加密 op 砍到只剩 2 个:Web Crypto 的 Rust 实现全链路拆解

📅 发布时间:2026/9/8 22:51:26
Deno 把加密 op 砍到只剩 2 个:Web Crypto 的 Rust 实现全链路拆解
Deno 把加密 op 砍到只剩 2 个Web Crypto 的 Rust 实现全链路拆解【免费下载链接】denoA modern runtime for JavaScript and TypeScript.项目地址: https://gitcode.com/GitHub_Trending/de/deno为什么 Deno 加密扩展的 ops 列表会从一排op_crypto_*缩到只剩两个看 lib.rs 里的扩展宏ops字段确实只剩两项。其余的算法逻辑去哪了它们被收编进了挂在 V8 堆上的 Rust 类的方法体里——这正是这个仓库里 Web Crypto API 的 Rust 加密实现所依赖的 cppgc 架构转变。第一幕 · 一次 sign() 调用的全链路老设计里每个 WebCrypto 方法对应一个独立 opJS 方法体负责组装参数、序列化密钥字节、跨界调用、再等结果。问题在于每次调用都要付一遍跨界成本JS 方法体本身大半是转发。于是这个仓库做了反向改造方法变成 Rust 对象上的原生方法JS 层做薄。扩展宏依赖 deno_webidl 与 deno_web 两个 crateops里只剩两项objects里注册三个类deno_core::extension!(deno_crypto, 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 ], );现在追一次crypto.subtle.sign(key, alg, data)。JS 入口先被makeAsyncForwarder包住规范要求 SubtleCrypto 所有方法都返回 Promise但 converter 层的同步抛错比如TypeError: Missing modulusLength本来会以同步 throw 的形式出现WPT 的promise_rejects_dom会因此看到错误形状包一层 async 后一律变成 rejection顺手还把Function.length对齐到 WebIDL 必需参数个数unwrapKey 是 7deriveKey 是 5。穿过这层薄包装调用落到挂在 V8 堆上的 SubtleCrypto 方法subtle_crypto.rs校验品牌后把活交给 subtle_sign.rs 的run。run 先做前置检查算法名必须与密钥上的算法一致密钥必须带sign用途。然后经 tokio 的spawn_blocking进入同步内核sign_key_sync——签大块的 RSA/ECDSA 是 CPU 密集活不能堵在运行时主循环上。这个分派函数在 lib.rs 里按Algorithm枚举走RSASSA-PKCS1-v1_5 分支用RsaPrivateKey::from_pkcs1_der解出私钥再经SigningKeyD签名RSA-PSS 分支额外要求salt_length缺了就抛 TypeError每次用OsRng取随机盐再Pss::new_with_salt::D(salt_len)ECDSA 按 named_curveP-256/P-384/P-521从 PKCS#8 解私钥对 prehash 做sign_prehash产出 raw r||s。这里有个小坑P-521 分支会把短于 33 字节的哈希左补零满足 bits2field 的最小长度要求key.rs 里FromCryptoHash for HmacAlgorithm的 SHA3 分支则标记了unreachable因为 SHA3 只支持 digest 不支持 HMACJS 层保证走不到那里。错误返回路径同样由规范驱动。所有失败汇聚到CryptoError枚举每个变体用#[class(...)]属性钉死对应 JS 异常类#[class(type)] #[error(Missing argument hash)] MissingArgumentHash, #[class(DOMExceptionOperationError)] #[error(decryption error - integrity check failed)] DecryptionError, #[class(DOMExceptionNotSupportedError)] #[error(Algorithm {0} is not supported)] UnsupportedDigestAlgorithm(String), // …另有 DOMExceptionQuotaExceededError65536 字节熵上限等变体于是「缺 hash」在 JS 侧看到的是TypeError: Missing argument hashAEAD 认证标签校验失败看到的是OperationError: decryption error - integrity check failed——错误名与消息全靠属性标注对上规范不在 JS 里手工拼装。第二幕 · 密钥为什么不再过 JS 边界先看 key_store.rs 文档注释里记录的演进历史上每个CryptoKey的密钥字节都住在 00_crypto.js 的 JSWeakMapKEY_STORE里每次加密操作都要把它们序列化后传给 op 一次。代价是双份的。一份是每次操作的序列化无论签名还是验签多频繁密钥字节都要完整跨一次 JS/Rust 边界。另一份是生命周期管理JS 侧存放只能依赖 WeakMap 的被动回收存在引用还在、回收却拖着的窗口。新方案把密钥材料改成 Rust 数据包进一个 V8 GC 对象CryptoKeyHandle实现GarbageCollectedJS 的CryptoKey只持有这个 handle。handle 像保险柜编号真正的密钥锁在 V8 堆上的 Rust 对象里——CryptoKey被 GC 回收时密钥材料自动释放连FinalizationRegistry都不用。旧方案新方案存放位置JS WeakMapKEY_STORERust CryptoKeyHandleV8 GC 对象每次操作序列化密钥字节跨边界传 handle字节不动释放时机被动依赖 WeakMapV8 GC 自动回收密钥材料的形态由 shared.rs 的RawKeyData枚举表达共五个变体pub enum RawKeyData { Secret(Box[u8]), Private(Box[u8]), Public(Box[u8]), Raw(Box[u8]), // seed 在从展开后的私钥字节导入时为 None // 此时导出 raw-seed/jwk/pkcs8 格式会被正确拒绝 SeededPrivate { seed: OptionBox[u8], private_key: Box[u8], }, }前三个带用途标签HMAC 密钥、PKCS8 私钥、SPKI 公钥之类Raw原样存字节、不带标签Ed25519/X25519/ML-KEM 公钥等走这里SeededPrivate是 FIPS 203/204 算法ML-KEM 解封装密钥、ML-DSA 签名密钥的复合素材private_key是展开后的密钥字节seed是用于派生的短种子。seed这个Option的语义很精确——从展开私钥字节导入的密钥没有种子取None那么导出raw-seed/jwk/pkcs8格式会被正确拒绝对应的报错路径在 node_interop.rs 里。第三幕 · 128 个 UUID 与一个种子一个 u64 能改变整条随机数路径。runtime/worker.rs 的WorkerOptions携带seed: Optionu64经扩展的args(options.seed)透传有值时 lib.rs 扩展宏的 state 子句会把StdRng::seed_from_u64(seed)放进OpState得到可复现的伪随机流主要服务测试与快照场景。JS 侧在getCryptoSingleton()铸造单例时调一次op_crypto_is_seeded()把结果记进usesSeededRng标志——这是 seed 从 Rust 状态反哺 JS 分派的闭环。分派逻辑很短function randomUUID() { if (this ! cryptoSingleton || usesSeededRng) { return FunctionPrototypeCall(cppgcRandomUUID, this); } if (uuidBatch UUID_BATCH_SIZE) { uuidBatchData op_crypto_random_uuid_batch(); uuidBatch 0; } const start uuidBatch * UUID_STRING_BYTES; return StringPrototypeSlice(uuidBatchData, start, start UUID_STRING_BYTES); }普通路径走批量缓存op_crypto_random_uuid_batch()一次取回UUID_BATCH_SIZE 128条完整字符串每条 36 字节UUID_STRING_BYTES之后的调用在纯 JS 里切片。Rust 侧的fast_uuid_v4_byteslib.rs 底部在 16 字节上就地打好 UUIDv4 的版本位与变体位再用HEX_CHARS查表直接拼 36 字节字符串并有test_fast_uuid_v4_correctness与uuidcrate 对拍正确性。种子路径则逐条走原生 cppgc 方法。这么做是为了保留精确的 RNG 调用顺序让确定性场景下的随机数与无种子版本逐步对齐。第四幕 ⚠️ · WPT 钉死的错误名拿 digest 模块做样本能看到 WPT 兼容工程的三件事。第一件是「推迟错误」模式。converter 层遇到未登记的算法名不直接抛TypeError而是保留为DigestAlgorithm::Unknown(name)推迟到run()抛规范要求的NotSupportedError。为什么绕这一道因为 WPT 的digest.https.any.html里若干子测试硬编码了错误名converter 层抛出的TypeError类名不对测试直接红。第二件是 XOF 参数校验的边界。除SHA-1、SHA-256/384/512、SHA3-256/384/512外还登记了 cSHAKE128/256、TurboSHAKE128/256、KT128、KT256、KangarooTwelve 七个 XOF 算法名称匹配不区分大小写。run_xof校验outputLength为 8 的倍数、TurboSHAKE/KangarooTwelve 的outputLength非零、domainSeparation落在 [0x01, 0x7F]违规统一报InvalidXofParametersOperationError。其中domainSeparation的读取有个防回绕的坑// 先按 u32 读完整值再拒绝 0xFF防止 0x101 回绕成 // 0x01 绕过 [0x01, 0x7F] 的 domainSeparation 范围检查 let u val.uint32_value(scope).unwrap_or(0); if u u8::MAX as u32 { return Err(WebIdlError::other( prefix, context.borrowed(), JsErrorBox::type_error(format!({key} must be in the range [0, 0xFF])), )); } Ok(Some(u as u8))第三件是 BufferSource 转换。自定义的WebIdlConverter for BufferSource把ArrayBuffer/ArrayBufferView物化成Vecu8保证跨.await之后仍安全并显式拒绝SharedArrayBuffer及其视图——这正是 WebIDLBufferSource不带[AllowShared]的契约。这类边界细节是从 JS 迁到 Rust 时逐条保留下来的。第五幕 · README 落了三处灰README 比代码老。引用时容易踩的坑有三处。一处是种子注入接口。README 写着要在RuntimeOptions里提供deno_crypto::deno_crypto::init(Optionu64)当前 runtime/worker.rs 改用deno_crypto::deno_crypto::args(options.seed)构造扩展参数对应宏里的maybe_seedWeb Worker 路径仍是init(options.seed)快照路径则是lazy_init()。种子语义没变有 seed 时 OpState 里放确定性StdRng。一处是「无独立 ops」。README 的 Surface 一节声称没有 standalone ops现实留了两个例外op_crypto_random_uuid_batch服务 JS 批量 UUID 快路径op_crypto_is_seeded向 JS 暴露 seed 状态。扩展声明宏上方那段注释其实已经承认了这一点——「standalone ops select the seeded randomUUID path」文档只是没跟上。一处是全局挂载代码。README 里Object.defineProperty(globalThis, ...)的示例是嵌入方视角Deno 本体的运行时全局绑定现在由 runtime/js/98_global_scope_shared.js 完成loadExtScript之后挂全局。扩展脚本导出的是Crypto、gettercrypto、CryptoKey、SubtleCrypto外加两个 Node.jsKeyObject互用函数cryptoKeyExportNodeMaterial与importCryptoKeySync。想读源码从这里下刀推荐顺序先看 lib.rs 的扩展声明与CryptoError建立「哪种错误落成哪种 DOMException 类」的地图再看 00_crypto.js弄清薄包装干的杂活——单例惰性铸造、async 转发、structured-clone 复活回调然后按目标操作钻进对应的 subtle_*.rs 与 lib.rs 里的同步分派内核密钥存放的疑问去 key_store.rs 和 shared.rs 找答案行为边界用 tests/unit/webcrypto_test.ts 与 webcrypto_mldsa_test.ts 的断言核对。【免费下载链接】denoA modern runtime for JavaScript and TypeScript.项目地址: https://gitcode.com/GitHub_Trending/de/deno创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考