diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7187d5d1..5c01dcca 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -62,6 +62,7 @@ jobs: examples/todomvc/guest providers/s3/panel providers/dropbox/panel + runtime/device-seal cache-all-crates: true cache-bin: false # The SAME entry the Pages build job saves — the expression must diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 12a206ba..46e3e57e 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -69,6 +69,7 @@ jobs: examples/todomvc/guest providers/s3/panel providers/dropbox/panel + runtime/device-seal # No registry pruning: the sibling workspace sits outside the # repo, and its deps must survive the post-job save. cache-all-crates: true diff --git a/demo/justfile b/demo/justfile index ae2fe870..9e838c21 100644 --- a/demo/justfile +++ b/demo/justfile @@ -22,6 +22,13 @@ translate: engine deno run --allow-read --allow-write --config deno.json ../runtime/tools/translate.ts \ ../engine/target/composed.wasm build/engine.plan.json +# THE DEVICE SEAL COMPONENT (runtime/device-seal/): the worker host fetches +# its plan and wasm BESIDE the engine's (worker.ts `fetchSealArtifacts`), +# so every recipe that ships the engine ships this too. Its own justfile +# builds, validates and translates it. +seal: + cd ../runtime/device-seal && just build + # Local infra: iroh relay (ws on 3340) + MinIO (9000) with CORS open. infra: #!/usr/bin/env bash @@ -137,7 +144,7 @@ check: bash scripts/check-invariants.sh # Assemble the servable demo directory. -site: translate app panels check +site: translate seal app panels check mkdir -p serve deno check host/demo.ts host/solo.ts host/solo-worker.ts ../visor/frame/frame.ts ../visor/frame/frame-backend.ts deno bundle --platform browser --minify --external node-datachannel --external "node-datachannel/polyfill" --external werift -o serve/demo.js host/demo.ts @@ -181,6 +188,7 @@ site: translate app panels check cp build/panel-dropbox.component.wasm build/panel-dropbox.plan.json serve/ cp ../engine/target/composed.wasm serve/engine.component.wasm cp build/engine.plan.json serve/engine.plan.json + cp ../runtime/device-seal/build/device-seal.component.wasm ../runtime/device-seal/build/device-seal.plan.json serve/ sed "s/__BUILD__/$(git rev-parse --short HEAD)-$(date +%s)/g" web/index.html > serve/index.html sed "s/__BUILD__/$(git rev-parse --short HEAD)-$(date +%s)/g" web/solo.html > serve/solo.html # THE OAUTH CALLBACK PAGE (DRIVE.md §3): the registered Drive @@ -198,7 +206,7 @@ serve: site # todomvc spike: docs/ is served by GitHub Pages, the build is # committed). The page still needs LOCAL infra (relay + MinIO) or # ?relay=&s3= overrides — Pages hosts the artifact, not the network. -pages: translate app panels check +pages: translate seal app panels check mkdir -p ../docs/demo deno check host/demo.ts host/solo.ts host/solo-worker.ts ../visor/frame/frame.ts ../visor/frame/frame-backend.ts deno bundle --platform browser --minify --external node-datachannel --external "node-datachannel/polyfill" --external werift -o ../docs/demo/demo.js host/demo.ts @@ -216,6 +224,7 @@ pages: translate app panels check cp build/panel-dropbox.component.wasm build/panel-dropbox.plan.json ../docs/demo/ cp ../engine/target/composed.wasm ../docs/demo/engine.component.wasm cp build/engine.plan.json ../docs/demo/ + cp ../runtime/device-seal/build/device-seal.component.wasm ../runtime/device-seal/build/device-seal.plan.json ../docs/demo/ sed "s/__BUILD__/$(git rev-parse --short HEAD)-$(date +%s)/g" web/index.html > ../docs/demo/index.html sed "s/__BUILD__/$(git rev-parse --short HEAD)-$(date +%s)/g" web/solo.html > ../docs/demo/solo.html # THE OAUTH CALLBACK PAGE (DRIVE.md §3) — see `site` above; same diff --git a/runtime/PERSISTENCE.md b/runtime/PERSISTENCE.md index e97de4db..7aa9c90d 100644 --- a/runtime/PERSISTENCE.md +++ b/runtime/PERSISTENCE.md @@ -149,6 +149,20 @@ engine, and same-browser multi-device is just two workers. ## Sealing +**Where the seal lives (2026-09-05): in a component.** The DEK, the KEK +ladder, the PMSEALv1 file format and the signing-handle mint/validate +described below are implemented in `runtime/device-seal/` — a wasm +component (`polyvisor:device-seal@0.1.0`, contract `wit/world.wit`) +reaching WebCrypto through `polymorph:webcrypto`, instantiated by the +worker beside the engine. Nothing in this section's RULES changed; what +changed is who holds the results: the unsealed DEK is a resource inside +the component and no JavaScript variable, the component's reach is the +one device namespace its `namespace` import closed over, and the wrap +records are byte-identical (`tests/devstore/fixtures/legacy-seal-v1.json` +is a pre-component device the matrix opens every run). The PRF +derivation stays on the page as ruled below; the derived KEK enters the +component as a non-extractable `kw-key`. + Two protected classes, deliberately split: **Bulk state** — keyhive archive, checkpoint blobs, us-doc working diff --git a/runtime/README.md b/runtime/README.md index 4573d0d8..a6fd5ba9 100644 --- a/runtime/README.md +++ b/runtime/README.md @@ -39,14 +39,29 @@ relative path. is this module family's vocabulary.** The unsealed `index.ts` (what may exist before unseal, and the long list of what may never), `namespace.ts` (one IndexedDB database plus one OPFS directory per - device, strictly partitioned), `seal.ts` (the per-device DEK and the - KEK ladder's v1 rungs), `identity-keys.ts` (non-extractable signing - handles persisted per device, with validate-on-load — absorbed here - from the webcrypto port by the #391 ruling: storing a handle is a - browser capability, not a WebCrypto one), `sealed-fs.ts` (an OPFS - directory proxy that seals the engine's state root while the guest - sees plaintext), `locks.ts` (the device lock, the lease, and the T0 - sweep) and `anchor.ts` (the tab's sessionStorage pointer). + device, strictly partitioned), `seal-records.ts` (the record shapes the + seal writes, the IndexedDB keys they rest under, the typed `SealError`, + and `getPrfEnrollment` — the one reader that needs no key), + `seal-component.ts` (the adapter over the DEVICE SEAL COMPONENT, which + is where the per-device DEK, the KEK ladder's v1 rungs, the PMSEALv1 + file format and the non-extractable signing handles now live — + `device-seal/`, `polyvisor:device-seal@0.1.0`), `sealed-fs.ts` (an OPFS + directory proxy that seals the engine's state root while the guest sees + plaintext, calling the component for the bytes), `locks.ts` (the device + lock, the lease, and the T0 sweep) and `anchor.ts` (the tab's + sessionStorage pointer). + + **The seal is a component** (`device-seal/wit/world.wit` is its + contract, and every doc comment in it is normative). What the boundary + buys is that THE UNSEALED DEK EXISTS NOWHERE IN JAVASCRIPT: the worker + holds a component and asks it to seal and open bytes, with no handle to + export. Its reach is its imports — it can spell five record kinds and + four key slots of ONE device's namespace, through the host-implemented + `namespace` interface seal-component.ts builds, and cannot name + another. The on-disk format is UNCHANGED, which is a requirement rather + than a convenience: `tests/devstore/fixtures/legacy-seal-v1.json` is a + device sealed by the pre-component TypeScript, and the matrix's + `legacy-unseal` row opens it through the component every run. **The worker host** sits on top of all of it and changes none of it: `worker.ts` is a SharedWorker ENTRY POINT — one worker per device, the @@ -81,12 +96,15 @@ relative path. nothing in the device store writes one to storage. - **Platform posture is the default.** At attach the worker loads (or mints) the device's non-extractable Ed25519 pair from the namespace - (`identity-keys.ts`) and hands it to the engine through the - app-owned `polyvisor:engine/device-identity@0.1.0` import, built - with `SigningKey.fromCryptoKey`/`VerifyingKey.fromCryptoKey` off the - SAME `@polymorph/webcrypto` module `newEngine` builds the port's own - fragment from — module identity matters here, because a wrapper from - a second copy of the package is not one the port recognizes. So the + (the seal component's `identity` interface) and hands it to the + engine through the app-owned + `polyvisor:engine/device-identity@0.1.0` import. The pair arrives + ALREADY as `SigningKey`/`VerifyingKey`, because seal-component.ts + instantiates the seal with the SAME `@polymorph/webcrypto` module + `newEngine` builds the port's own fragment from — module identity + matters here, because a wrapper from a second copy of the package is + not one the port recognizes, and one class family for both + components is what makes the handoff a no-op. So the device's private key is never written into a checkpoint; a resumed device is the same device because the platform still holds its key. A checkpoint written in the older `seed` posture still resumes: the @@ -125,13 +143,18 @@ example of that mapping so far. **Package-free** (only the platform and their own siblings, so they type-check under any embedder's config and cannot be mis-pinned): -`index.ts`, `namespace.ts`, `names.ts`, `idb.ts`, `seal.ts`, -`sealed-fs.ts`, `identity-keys.ts`, `locks.ts`, `anchor.ts` — and -`rpc.ts`, whose only imports are types, which erase. `sealed-fs.ts` -declares the OPFS handle interfaces `@polyengine/wasi/filesystem-web` -consumes rather than importing them, which is what buys its place here. - -**Needs the embedder pin**: `worker.ts` always did — hosting a device +`index.ts`, `namespace.ts`, `names.ts`, `idb.ts`, `seal-records.ts`, +`sealed-fs.ts`, `locks.ts`, `anchor.ts` — and `rpc.ts`, whose only +imports are types, which erase. `sealed-fs.ts` declares the OPFS handle +interfaces `@polyengine/wasi/filesystem-web` consumes rather than +importing them, which is what buys its place here; it takes the sealing +functions as a parameter for the same reason, so the proxy stays +package-free while the bytes come from the component. + +**Needs the embedder pin**: `seal-component.ts` does, and unavoidably — +instantiating the seal means the polyengine embedder and the webcrypto +port, so it is NOT re-exported from `mod.ts` (a consumer that only reads +the index to render a picker still needs no pins). `worker.ts` always did — hosting a device means instantiating the engine, which is exactly why it is an entry point the embedder bundles rather than something `mod.ts` re-exports. `client.ts` joined it at 0.4.0, for one import: `fromCloneable`, which diff --git a/runtime/device-seal/.gitignore b/runtime/device-seal/.gitignore new file mode 100644 index 00000000..6438f1c0 --- /dev/null +++ b/runtime/device-seal/.gitignore @@ -0,0 +1,2 @@ +target/ +build/ diff --git a/runtime/device-seal/Cargo.lock b/runtime/device-seal/Cargo.lock new file mode 100644 index 00000000..6ec7dcae --- /dev/null +++ b/runtime/device-seal/Cargo.lock @@ -0,0 +1,433 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "device-seal" +version = "0.0.0" +dependencies = [ + "polymorph-webcrypto-guest", + "wit-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polymorph-webcrypto-guest" +version = "0.1.0" +source = "git+https://github.com/polymorph-components/polymorph-webcrypto?rev=0cde56168117277f7157b1285e48a980d8c37a10#0cde56168117277f7157b1285e48a980d8c37a10" +dependencies = [ + "futures", + "wit-bindgen", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-encoder" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59972d6cd272259de647b7c1f1912e45e289c75ffd4be04e10695507cd7e1b59" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3f45816ef616806f48498bcd831377de578c4fa51db0c83ab8ceb78cc13523b" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19db11f87d2486580e1e8b6f494c54df7e0566b87d0b599db843c24019667339" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94c5e45f6d4cfaca727c1c48989ab3e05bb289bf84fbad226e1cfbbef2c04b7f" +dependencies = [ + "bitflags", + "futures", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cf25dc4bb1981c16aa549ec66c6762b7752bfb8b7c3b3936ae13100298dc72" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "407ee2b474af1a366766fe91b8255b7935e5e3c3afb9c304e91ac3d154287ff1" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37386eba32427684ffe37a0f765f63ce2ece03efb1ad28c23cf69562fdc67ec0" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbbd2500ac3488489ee8c6e59b79d7e47e6da5bfb019efd35d5dca57b78af624" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.253.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d997b8e5920fcbeec742b58e583325d6419a6aca617ae8075c406a61c65ba8a" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/runtime/device-seal/Cargo.toml b/runtime/device-seal/Cargo.toml new file mode 100644 index 00000000..1c64ecbb --- /dev/null +++ b/runtime/device-seal/Cargo.toml @@ -0,0 +1,36 @@ +# The device seal as a component (runtime/device-seal/wit/world.wit). +# +# STANDALONE PACKAGE, deliberately: no parent directory in this repo +# carries a Cargo.toml, and this crate pins a webcrypto guest revision of +# its own, so an empty `[workspace]` table makes it its own workspace root +# rather than something a future parent manifest could silently absorb. +[workspace] + +[package] +name = "device-seal" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +# `cdylib` is the component; `rlib` so `cargo test` can link the pure +# modules natively (the bindings-touching ones are cfg'd out there). +crate-type = ["cdylib", "rlib"] + +[dependencies] +# polymorph:webcrypto guest bindings. This crate binds the whole +# `polymorph:webcrypto` import surface once; `src/lib.rs`'s `generate!` +# remaps every one of those interfaces onto it (see the comment there). +polymorph-webcrypto-guest = { git = "https://github.com/polymorph-components/polymorph-webcrypto", rev = "0cde56168117277f7157b1285e48a980d8c37a10" } + +# Must match polymorph-webcrypto-guest's wit-bindgen minor so the two +# `generate!` expansions share one async runtime (engine/guest/Cargo.toml +# records why). `async-spawn` is the feature the async export shape needs. +wit-bindgen = { version = "0.59", features = ["async", "async-spawn"] } + +[profile.release] +opt-level = "s" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true diff --git a/runtime/device-seal/justfile b/runtime/device-seal/justfile new file mode 100644 index 00000000..01a2ea3b --- /dev/null +++ b/runtime/device-seal/justfile @@ -0,0 +1,28 @@ +# The device seal component (wit/world.wit). + +# Build the component, validate it, and translate it to a polyengine plan. +build: + cargo build --target wasm32-wasip2 --release + mkdir -p build + cp target/wasm32-wasip2/release/device_seal.wasm build/device-seal.component.wasm + wasm-tools validate --features component-model,cm-async build/device-seal.component.wasm + deno run --allow-read --allow-write --config ../../demo/deno.json \ + ../tools/translate.ts build/device-seal.component.wasm build/device-seal.plan.json + +# The pure rules — the ladder's record validation and the PMSEALv1 +# layout — natively, with no platform in sight. +test: + cargo test + +check: + cargo clippy --target wasm32-wasip2 --release -- -D warnings + cargo clippy --all-targets -- -D warnings + +# The contract. Read-only here: a defect in it is reported, not edited around. +wit: + wasm-tools component wit wit/ + +# What the artifact ACTUALLY imports — the authority on the component's +# reach (world.wit:336-338), unused interfaces having been stripped. +imports: build + wasm-tools component wit build/device-seal.component.wasm | rg '^\s*import' diff --git a/runtime/device-seal/rust-toolchain.toml b/runtime/device-seal/rust-toolchain.toml new file mode 100644 index 00000000..57fb8250 --- /dev/null +++ b/runtime/device-seal/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +# Matches engine/rust-toolchain.toml (the repo-wide pin). +channel = "1.97.0" +components = ["rustfmt", "clippy"] +targets = ["wasm32-wasip2"] diff --git a/runtime/device-seal/src/component.rs b/runtime/device-seal/src/component.rs new file mode 100644 index 00000000..2921dc0c --- /dev/null +++ b/runtime/device-seal/src/component.rs @@ -0,0 +1,505 @@ +//! THE EXPORTS: `seal`, `sealed`, `identity`. +//! +//! Each function is seal.ts's, cited by name, with one change of shape — +//! NO FUNCTION RETURNS A DEK. Where seal.ts handed back a +//! non-extractable `CryptoKey`, these park it (`state`) and the `sealed` +//! interface spends it. + +use polymorph_webcrypto_guest::{Aead, KwKey, SigningKey, VerifyingKey}; + +use crate::exports::polyvisor::device_seal::identity::Guest as IdentityGuest; +use crate::exports::polyvisor::device_seal::seal::Guest as SealGuest; +use crate::exports::polyvisor::device_seal::sealed::Guest as SealedGuest; +use crate::file_format::{self, Framed}; +use crate::ladder::{self, Res}; +use crate::polyvisor::device_seal::namespace; +use crate::polyvisor::device_seal::types::{ + IdentitySlot, PassphraseOrigin, PrfEnrollment, SealCode, SealError, SealState, +}; +use crate::records::{self, Code, Refusal}; +use crate::{identity, state}; + +struct Component; + +/// Lower a [`Refusal`] onto the WIT variant. `platform` carries the +/// platform's own sentence and never key material. +fn lower(refusal: Refusal) -> SealError { + SealError { + code: match refusal.code { + Code::WrongPassphrase => SealCode::WrongPassphrase, + Code::WrongPasskey => SealCode::WrongPasskey, + Code::NoRung => SealCode::NoRung, + Code::AlreadySealed => SealCode::AlreadySealed, + Code::Tampered => SealCode::Tampered, + Code::Unsupported => SealCode::Unsupported, + }, + message: refusal.message, + } +} + +fn out(result: Res) -> Result { + result.map_err(lower) +} + +// --- seal -------------------------------------------------------------------- + +impl SealGuest for Component { + /// Which rungs this device HAS, asked without opening anything + /// (seal.ts `sealState`). Deliberately does no shape validation: the + /// picker's question is "does a record exist", and a device whose + /// record is malformed still has the rung, as the ceremony that tries + /// it will report. + async fn state() -> SealState { + let passphrase = namespace::get_passphrase_wrap().await; + let platform = namespace::get_platform_wrap().await; + let prf = namespace::get_prf_wrap().await; + let origin = passphrase + .as_ref() + .map(|rec| rec.origin.map(from_wit_origin)); + let (passphrase, user_passphrase, until_reseal, prf) = + records::seal_state(origin, platform.is_some(), prf.is_some()); + SealState { + passphrase, + user_passphrase, + until_reseal, + prf, + } + } + + fn unsealed() -> bool { + state::unsealed() + } + + fn forget() { + state::forget(); + } + + /// Mint the DEK and seal it under a passphrase (seal.ts + /// `createSealedDek`). + /// + /// REFUSES ON A DEVICE THAT ALREADY HAS A RUNG rather than replacing + /// it: a second mint would produce a second DEK, and every byte + /// written under the first would become unreadable with no error + /// anywhere. + /// + /// The order of the two refusals is seal.ts's (332-335): + /// `already-sealed` is decided BEFORE the passphrase is inspected, so + /// an empty passphrase offered to a sealed device reports the rung it + /// hit, not the argument it carried. + async fn create_sealed_dek(passphrase: String, origin: PassphraseOrigin) -> Result<(), SealError> { + out(create_sealed_dek(&passphrase, origin).await) + } + + /// THE LOGIN (seal.ts `unsealWithPassphrase`). Parks the DEK. + async fn unseal_with_passphrase(passphrase: String) -> Result<(), SealError> { + out(unseal_with_passphrase(&passphrase).await) + } + + /// Change the passphrase. THE SALT ROTATES and the DEK does not + /// (seal.ts `rekeyPassphrase`): rotating the DEK would mean + /// re-encrypting every sealed byte, and the threat this rung answers + /// is answered by the new derivation. + async fn rekey_passphrase(old: String, new: String) -> Result<(), SealError> { + out(rekey_passphrase(&old, &new).await) + } + + /// Arm `until-reseal` (seal.ts `enableUntilReseal`). ADDITIVE — the + /// passphrase rung stays, because it is the only thing that can open + /// the device after a reseal. + async fn enable_until_reseal(passphrase: String) -> Result<(), SealError> { + out(enable_until_reseal(&passphrase).await) + } + + /// THE PROMOTION SEAM (seal.ts `rekeyFromPlatform`): give a + /// platform-rung device a passphrase it did not have, authorised by + /// the platform wrap. Marks the rung `user` — the point of the + /// ceremony is that what it leaves behind is a door somebody knows. + async fn rekey_from_platform(new: String) -> Result<(), SealError> { + out(rekey_from_platform(&new).await) + } + + /// Open from the platform wrap (seal.ts `unsealFromPlatform`). + async fn unseal_from_platform() -> Result { + out(unseal_from_platform().await) + } + + /// The PRF rung's ceremony half, for the page's assertion (seal.ts + /// `getPrfEnrollment`). THE WRAPPED BYTES ARE NOT RETURNED: the page + /// has no use for them and no way to open them. + async fn get_prf_enrollment() -> Result, SealError> { + out(get_prf_enrollment().await) + } + + /// Enrol a passkey rung (seal.ts `enablePrf`). + async fn enable_prf( + kek: polymorph_webcrypto_guest::bindings::key_wrap::KwKey, + enrollment: PrfEnrollment, + passphrase: Option, + ) -> Result<(), SealError> { + out(enable_prf(KwKey::from_raw(kek), enrollment, passphrase).await) + } + + /// Open with the page-derived KEK (seal.ts `unsealWithPrf`). Parks + /// the DEK. + async fn unseal_with_prf( + kek: polymorph_webcrypto_guest::bindings::key_wrap::KwKey, + ) -> Result<(), SealError> { + out(unseal_with_prf(KwKey::from_raw(kek)).await) + } + + /// Delete the platform wrap and its key, AND NOTHING ELSE (seal.ts + /// `reseal`). The passphrase wrap and the PRF wrap survive: an + /// assertion per unseal is the PRF rung's whole point, so what it + /// leaves behind opens nothing on its own. + async fn reseal() { + namespace::delete_platform_wrap().await; + namespace::delete_platform_kek().await; + } +} + +fn from_wit_origin(origin: PassphraseOrigin) -> records::Origin { + match origin { + PassphraseOrigin::User => records::Origin::User, + PassphraseOrigin::Generated => records::Origin::Generated, + } +} + +async fn create_sealed_dek(passphrase: &str, origin: PassphraseOrigin) -> Res<()> { + if namespace::get_passphrase_wrap().await.is_some() { + return Err(Refusal::already_sealed()); + } + records::require_passphrase(passphrase)?; + let salt = ladder::fresh_salt(); + let kek = ladder::kek_from_passphrase(passphrase, &salt, records::PBKDF2_ITERATIONS).await?; + // Born extractable: `wrap` has to be able to serialize it. + let dek = ladder::generate_dek().await?; + let wrapped = ladder::wrap_dek(&dek, &kek).await?; + namespace::put_passphrase_wrap(ladder::passphrase_record( + salt, + wrapped.clone(), + origin, + )) + .await; + // PARK THE SAME KEY AS A NON-EXTRACTABLE HANDLE, not the wrappable + // local (seal.ts:349-351): the component holds this for a session. + let parked = ladder::unwrap_dek(&wrapped, &kek, false) + .await + .map_err(ladder::platform)?; + state::park(parked); + Ok(()) +} + +async fn unseal_with_passphrase(passphrase: &str) -> Res<()> { + let rec = namespace::get_passphrase_wrap() + .await + .ok_or_else(Refusal::no_passphrase_rung)?; + records::validate_passphrase_wrap(rec.iterations, &rec.salt, &rec.wrapped)?; + // THE ITERATION COUNT COMES FROM THE RECORD, never the constant: a + // device sealed under an older floor must still open. + let kek = ladder::kek_from_passphrase(passphrase, &rec.salt, rec.iterations).await?; + // Nothing is written and nothing cached on failure; the caller learns + // exactly one bit. + let dek = ladder::unwrap_dek(&rec.wrapped, &kek, false) + .await + .map_err(|_| Refusal::wrong_passphrase())?; + state::park(dek); + Ok(()) +} + +async fn rekey_passphrase(old: &str, new: &str) -> Res<()> { + records::require_passphrase(new)?; + let dek = ladder::wrappable_dek(old).await?; + let salt = ladder::fresh_salt(); + let kek = ladder::kek_from_passphrase(new, &salt, records::PBKDF2_ITERATIONS).await?; + let wrapped = ladder::wrap_dek(&dek, &kek).await?; + // ONE WRITE, after every fallible step has succeeded: a failed re-key + // leaves the old passphrase working. A person chose this one, + // whatever the rung it replaces was. + namespace::put_passphrase_wrap(ladder::passphrase_record( + salt, + wrapped, + PassphraseOrigin::User, + )) + .await; + Ok(()) +} + +async fn enable_until_reseal(passphrase: &str) -> Res<()> { + let dek = ladder::wrappable_dek(passphrase).await?; + // Non-extractable, `wrap`/`unwrap` only: it cannot encrypt data, only + // hold the DEK. + let kek = polymorph_webcrypto_guest::aes_kw::generate_key( + polymorph_webcrypto_guest::aes_gcm::AesVariant::Aes256, + polymorph_webcrypto_guest::KwKeyOptions { + wrap: true, + unwrap: true, + extractable: false, + }, + ) + .await + .map_err(ladder::platform)?; + let wrapped = ladder::wrap_dek(&dek, &kek).await?; + // HANDLE FIRST, THEN THE WRAP (seal.ts:449-453): the pair is only + // meaningful together, and a wrap with no key is the state that would + // make `unseal-from-platform` report a rung it cannot use. + namespace::put_platform_kek(kek.as_raw()).await; + namespace::put_platform_wrap(namespace::PlatformWrap { wrapped }).await; + Ok(()) +} + +async fn rekey_from_platform(new: &str) -> Res<()> { + records::require_passphrase(new)?; + let dek = ladder::wrappable_dek_from_platform().await?; + let salt = ladder::fresh_salt(); + let kek = ladder::kek_from_passphrase(new, &salt, records::PBKDF2_ITERATIONS).await?; + let wrapped = ladder::wrap_dek(&dek, &kek).await?; + namespace::put_passphrase_wrap(ladder::passphrase_record( + salt, + wrapped, + PassphraseOrigin::User, + )) + .await; + Ok(()) +} + +async fn unseal_from_platform() -> Res { + // `ok(false)` when EITHER half is absent — a device that must be + // asked for its passphrase is the normal case, not an error + // (seal.ts:540-548, world.wit:255-258). + let rec = namespace::get_platform_wrap().await; + let kek = namespace::get_platform_kek().await; + let (Some(rec), Some(kek)) = (rec, kek) else { + return Ok(false); + }; + records::validate_platform_wrap(&rec.wrapped)?; + let kek = KwKey::from_raw(kek); + // Validate-on-load: a planted EXTRACTABLE key here would be an + // attacker's handle we then used to unwrap the DEK (seal.ts:546-552). + if kek.extractable() || !kek.can_unwrap() { + return Err(Refusal::platform_kek_unusable()); + } + let dek = ladder::unwrap_dek(&rec.wrapped, &kek, false) + .await + .map_err(|_| Refusal::platform_wrap_did_not_open())?; + state::park(dek); + Ok(true) +} + +async fn get_prf_enrollment() -> Res> { + Ok(ladder::read_prf_wrap().await?.map(|rec| PrfEnrollment { + credential_id: rec.credential_id, + transports: rec.transports, + rp_id: rec.rp_id, + prf_input: rec.prf_input, + hkdf_salt: rec.hkdf_salt, + })) +} + +async fn enable_prf( + kek: KwKey, + enrollment: PrfEnrollment, + passphrase: Option, +) -> Res<()> { + ladder::require_prf_kek(&kek)?; + // WHAT AUTHORIZES IT (seal.ts `enablePrf`, 651-709): preferentially + // the PLATFORM rung — a device at the promotion moment always has + // one, and its passphrase rung may well be the door with no key + // `sealT0` left behind. With the platform rung gone, the authority is + // the PASSPHRASE the sheet asked for. With neither, this refuses: + // there is no third authority, and a ceremony that re-wrapped a DEK + // on nobody's say-so would be one. + // + // The branch follows `until-reseal`, which is the platform WRAP's + // presence alone (seal.ts:687 reads `sealState`); a wrap whose key + // has gone missing therefore refuses `no-rung` from + // `wrappable_dek_from_platform` rather than falling through to the + // passphrase, exactly as the TypeScript does. + let dek: Aead = if namespace::get_platform_wrap().await.is_some() { + ladder::wrappable_dek_from_platform().await? + } else if let Some(passphrase) = &passphrase { + // An EMPTY string is an offered passphrase, not an absent one: + // seal.ts branches on `!== undefined`, so it reaches + // `wrappableDek` and refuses `wrong-passphrase`. Preserved. + ladder::wrappable_dek(passphrase).await? + } else { + return Err(Refusal::no_prf_authority()); + }; + let wrapped = ladder::wrap_dek(&dek, &kek).await?; + // ONE WRITE, after every fallible step: a failed enrollment leaves + // the device exactly as it was. + // + // IT DOES NOT DELETE THE PLATFORM WRAP (seal.ts:668-672). Shutting + // that door is the caller's half — worker.ts's `promote` calls + // `reseal` after this returns — because the decision "a user who + // asked to be asked must not leave a silent door standing" belongs to + // the ceremony that knows what the user chose, not to the re-wrap. + // PERSISTENCE.md's promotion paragraph describes that CALLER's + // sequence, not this function's. + namespace::put_prf_wrap(namespace::PrfWrap { + credential_id: enrollment.credential_id, + transports: enrollment.transports, + rp_id: enrollment.rp_id, + prf_input: enrollment.prf_input, + hkdf_salt: enrollment.hkdf_salt, + wrapped, + }) + .await; + Ok(()) +} + +async fn unseal_with_prf(kek: KwKey) -> Res<()> { + // The VALIDATED reader, so a malformed record refuses as `tampered` + // here too: "someone altered the record" and "the right record, the + // wrong key" are different facts and get different codes. + let rec = ladder::read_prf_wrap().await?.ok_or_else(Refusal::no_passkey_rung)?; + ladder::require_prf_kek(&kek)?; + // A FAILED UNWRAP IS ONE BIT: a wrong credential, a wrong PRF input, + // and a record copied in from another device are indistinguishable + // here by construction. + let dek = ladder::unwrap_dek(&rec.wrapped, &kek, false) + .await + .map_err(|_| Refusal::wrong_passkey())?; + state::park(dek); + Ok(()) +} + +// --- sealed ------------------------------------------------------------------ + +/// The parked DEK, or `no-rung`: the component is sealed +/// (world.wit:282-283). +fn dek() -> Res> { + state::dek().ok_or_else(Refusal::device_sealed) +} + +impl SealedGuest for Component { + /// Seal `bytes` under the DEK and store them at `key` (seal.ts + /// `sealedPut`). Fresh 12-byte IV per write — reuse under one key is + /// the failure mode that loses both confidentiality and integrity for + /// GCM — and THE KEY NAME IS THE ADDITIONAL DATA, so a valid value + /// cannot be moved from one name to another. + async fn put(key: String, bytes: Vec) -> Result<(), SealError> { + out(sealed_put(&key, &bytes).await) + } + + /// Open the sealed value at `key` (seal.ts `sealedGet`). A value that + /// is PRESENT BUT DOES NOT OPEN is `tampered`, never `none`: + /// "nothing stored" and "stored and altered underneath us" are + /// different facts. + async fn get(key: String) -> Result>, SealError> { + out(sealed_get(&key).await) + } + + /// Forget the value at `key`. + /// + /// CONTRACT: seal.ts `sealedDelete` (833-835) takes no DEK and works + /// on a sealed device — deleting ciphertext needs no key. The WIT + /// rules otherwise for the whole interface ("Every function refuses + /// with `no-rung` when nothing is parked", world.wit:282-283), and + /// the WIT is the pinned contract, so the gate applies here too. The + /// conservative reading also happens to be the narrower surface: a + /// sealed component offers no way to touch the namespace at all. + async fn delete(key: String) -> Result<(), SealError> { + out(sealed_delete(&key).await) + } + + /// Seal a whole file, PMSEALv1 (sealed-fs.ts `sealBytes`). Fresh IV + /// per commit, so re-sealing a file after a one-byte change never + /// reuses one under the DEK. + async fn seal_file(plaintext: Vec) -> Result, SealError> { + out(seal_file(&plaintext).await) + } + + /// Open a whole file (sealed-fs.ts `openBytes`). Bad magic, a short + /// header and a GCM failure are all `tampered`: a wrong DEK and + /// altered bytes are the same event to GCM and are reported as one. + async fn open_file(sealed: Vec) -> Result, SealError> { + out(open_file(&sealed).await) + } +} + +async fn sealed_put(key: &str, bytes: &[u8]) -> Res<()> { + let dek = dek()?; + let iv = ladder::random(records::IV_BYTES); + let ct = dek + .seal(&iv[..], records::sealed_aad(key), bytes) + .await + .map_err(ladder::platform)?; + namespace::put_sealed(key.to_string(), namespace::SealedValue { iv, ct }).await; + Ok(()) +} + +async fn sealed_get(key: &str) -> Res>> { + let dek = dek()?; + let Some(rec) = namespace::get_sealed(key.to_string()).await else { + return Ok(None); + }; + records::validate_sealed_value(key, &rec.iv, &rec.ct)?; + let opened = dek + .open(&rec.iv[..], records::sealed_aad(key), &rec.ct[..]) + .await + .map_err(|_| Refusal::sealed_value_did_not_open(key))?; + Ok(Some(opened.collect().await)) +} + +async fn sealed_delete(key: &str) -> Res<()> { + let _dek = dek()?; + namespace::delete_sealed(key.to_string()).await; + Ok(()) +} + +async fn seal_file(plaintext: &[u8]) -> Res> { + let dek = dek()?; + let iv = ladder::random(file_format::IV_BYTES); + // The additional data is the MAGIC, not the whole header — see + // `file_format::AAD`. + let body = dek + .seal(&iv[..], file_format::AAD, plaintext) + .await + .map_err(ladder::platform)?; + Ok(file_format::frame(&iv, &body)) +} + +async fn open_file(sealed: &[u8]) -> Res> { + let dek = dek()?; + match file_format::parse(sealed)? { + // A file the provider created and never wrote to has no header at + // all, and is an empty file rather than a broken one. + Framed::Empty => Ok(Vec::new()), + Framed::Sealed { iv, body } => { + let opened = dek + .open(iv, file_format::AAD, body) + .await + .map_err(|_| Refusal::file_did_not_open())?; + Ok(opened.collect().await) + } + } +} + +// --- identity ---------------------------------------------------------------- + +impl IdentityGuest for Component { + async fn load_or_mint( + slot: IdentitySlot, + ) -> Result<(SigningKeyRaw, VerifyingKeyRaw), SealError> { + out(identity::load_or_mint(slot).await.map(into_raw_pair)) + } + + async fn load( + slot: IdentitySlot, + ) -> Result, SealError> { + out(identity::load(slot).await.map(|pair| pair.map(into_raw_pair))) + } + + async fn delete(slot: IdentitySlot) -> Result<(), SealError> { + out(identity::delete(slot).await) + } +} + +type SigningKeyRaw = polymorph_webcrypto_guest::bindings::signature::SigningKey; +type VerifyingKeyRaw = polymorph_webcrypto_guest::bindings::signature::VerifyingKey; + +fn into_raw_pair(pair: (SigningKey, VerifyingKey)) -> (SigningKeyRaw, VerifyingKeyRaw) { + (pair.0.into_raw(), pair.1.into_raw()) +} + +crate::export!(Component with_types_in crate); diff --git a/runtime/device-seal/src/file_format.rs b/runtime/device-seal/src/file_format.rs new file mode 100644 index 00000000..09c07cdf --- /dev/null +++ b/runtime/device-seal/src/file_format.rs @@ -0,0 +1,190 @@ +//! PMSEALv1 — the per-file sealed format, ported from +//! runtime/device-store/sealed-fs.ts (the `MAGIC`/`HEADER`/`OVERHEAD` +//! constants at lines 120-131, `sealBytes` at 139-147, `openBytes` at +//! 149-167). +//! +//! BYTE-FOR-BYTE COMPATIBILITY IS THE REQUIREMENT, not a convenience: a +//! file written by sealed-fs.ts must open here and a file written here +//! must open there (world.wit:35-41, 320-328). Everything in this module +//! is the framing; the AEAD itself is the platform's and lives in +//! `src/component.rs`. +//! +//! The layout: +//! +//! ```text +//! 0 8 20 len +//! +----------------+---------------+-------------------------+ +//! | "PMSEALv1" | IV (12 bytes) | ciphertext ‖ GCM tag | +//! +----------------+---------------+-------------------------+ +//! ``` + +use crate::records::Refusal; + +/// `PMSEALv1`, 8 ASCII bytes. Present so a file that is NOT sealed is +/// diagnosed as such instead of decrypted into noise (sealed-fs.ts:120). +pub const MAGIC: &[u8; 8] = b"PMSEALv1"; +/// AES-GCM's nonce, fresh per commit of a file (sealed-fs.ts:121). +pub const IV_BYTES: usize = 12; +/// magic ‖ iv. +pub const HEADER: usize = MAGIC.len() + IV_BYTES; +/// AES-GCM's tag, trailing the ciphertext. +pub const TAG_BYTES: usize = 16; +/// What an empty file costs once sealed (sealed-fs.ts:124). +pub const OVERHEAD: usize = HEADER + TAG_BYTES; + +/// THE ADDITIONAL DATA IS THE MAGIC, NOT THE WHOLE HEADER +/// (sealed-fs.ts:127-131, world.wit:321-324). +/// +/// The magic is bound as AAD so an unsealed file cannot pass as a sealed +/// one and the version cannot be downgraded by an editor of the raw +/// bytes. The IV is not in the AAD because it does not need to be: GCM +/// authenticates its own nonce, so altering it fails the tag anyway. +pub const AAD: &[u8] = MAGIC; + +/// The 20-byte header for a fresh write. Panics on a wrong-length IV, +/// which is a caller bug: the only producer is the platform CSPRNG asked +/// for exactly [`IV_BYTES`]. +pub fn header(iv: &[u8]) -> [u8; HEADER] { + assert_eq!(iv.len(), IV_BYTES, "PMSEALv1 iv must be 12 bytes"); + let mut out = [0u8; HEADER]; + out[..MAGIC.len()].copy_from_slice(MAGIC); + out[MAGIC.len()..].copy_from_slice(iv); + out +} + +/// Frame a sealed body (ciphertext ‖ tag) behind its header — the +/// `sealBytes` tail (sealed-fs.ts:143-146). +/// +/// NOTE THE EMPTY CASE: sealing an empty plaintext produces a full +/// [`OVERHEAD`]-byte file, because `sealBytes` has no empty special case. +/// Only the READ side treats zero length as an empty file (see +/// [`parse`]); the asymmetry is sealed-fs.ts's and is preserved. +pub fn frame(iv: &[u8], body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(HEADER + body.len()); + out.extend_from_slice(&header(iv)); + out.extend_from_slice(body); + out +} + +/// What a sealed file's raw bytes are, once the framing is checked. +#[derive(Debug, PartialEq, Eq)] +pub enum Framed<'a> { + /// A ZERO-LENGTH FILE IS AN EMPTY FILE, not a broken one: the OPFS + /// provider's `openAt` creates the entry before anything is written + /// to it, so a legitimately empty file has no header at all + /// (sealed-fs.ts:149-153). + Empty, + /// The IV and the body (ciphertext ‖ tag) to hand to GCM. + Sealed { iv: &'a [u8], body: &'a [u8] }, +} + +/// Check the framing of a file read back. +/// +/// Bad magic and a short header are `tampered`, exactly as a GCM failure +/// is (world.wit:302-304): "not a sealed file" and "a sealed file that +/// was altered" are the same refusal to a caller and neither tells it +/// anything about the other. +pub fn parse(raw: &[u8]) -> Result, Refusal> { + if raw.is_empty() { + return Ok(Framed::Empty); + } + if raw.len() < OVERHEAD || &raw[..MAGIC.len()] != MAGIC { + return Err(Refusal::not_a_sealed_file()); + } + Ok(Framed::Sealed { + iv: &raw[MAGIC.len()..HEADER], + body: &raw[HEADER..], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An obviously-synthetic IV: the byte sequence 00 01 02 … 0b. + fn iv() -> Vec { + (0u8..IV_BYTES as u8).collect() + } + + #[test] + fn the_known_layout_is_magic_then_iv_then_body() { + assert_eq!(MAGIC.len(), 8); + assert_eq!(HEADER, 20); + assert_eq!(OVERHEAD, 36); + + let body = vec![0xAA; 5 + TAG_BYTES]; + let out = frame(&iv(), &body); + assert_eq!(out.len(), HEADER + body.len()); + assert_eq!(&out[0..8], b"PMSEALv1"); + assert_eq!(&out[8..20], iv().as_slice()); + assert_eq!(&out[20..], body.as_slice()); + } + + #[test] + fn the_additional_data_is_the_eight_magic_bytes() { + // sealed-fs.ts:127-131, world.wit:322-324. Not the 20-byte + // header: the IV is authenticated by GCM's own use of it. + assert_eq!(AAD, b"PMSEALv1"); + assert_eq!(AAD.len(), 8); + } + + #[test] + fn a_zero_length_file_is_an_empty_file_not_a_broken_one() { + assert_eq!(parse(&[]), Ok(Framed::Empty)); + } + + #[test] + fn round_trips_the_framing_it_writes() { + let body = vec![0x11; 3 + TAG_BYTES]; + let out = frame(&iv(), &body); + match parse(&out).expect("framing rejected its own output") { + Framed::Sealed { iv: got_iv, body: got_body } => { + assert_eq!(got_iv, iv().as_slice()); + assert_eq!(got_body, body.as_slice()); + } + Framed::Empty => panic!("a framed file parsed as empty"), + } + } + + #[test] + fn an_empty_plaintext_still_costs_a_full_header_and_tag() { + // `sealBytes` has no empty special case, so an empty file the + // guest actually wrote is 36 bytes on disk, and opens back to + // nothing. Only a file that was never written is zero length. + let out = frame(&iv(), &[0u8; TAG_BYTES]); + assert_eq!(out.len(), OVERHEAD); + assert!(matches!(parse(&out), Ok(Framed::Sealed { .. }))); + } + + #[test] + fn refuses_a_file_whose_magic_is_not_pmsealv1_as_tampered() { + // An unsealed file is diagnosed as such rather than decrypted + // into noise. + let plain = b"a plain text file long enough to clear the overhead...".to_vec(); + assert_eq!(parse(&plain), Err(Refusal::not_a_sealed_file())); + + // The version cannot be downgraded by an editor of the raw bytes: + // the magic is authenticated as AAD, and refused here besides. + let mut downgraded = frame(&iv(), &[0u8; TAG_BYTES]); + downgraded[7] = b'0'; + assert_eq!(parse(&downgraded), Err(Refusal::not_a_sealed_file())); + } + + #[test] + fn refuses_a_file_too_short_to_hold_a_header_and_tag_as_tampered() { + let full = frame(&iv(), &[0u8; TAG_BYTES]); + for len in 1..OVERHEAD { + assert_eq!( + parse(&full[..len]), + Err(Refusal::not_a_sealed_file()), + "a {len}-byte file was accepted" + ); + } + } + + #[test] + #[should_panic(expected = "PMSEALv1 iv must be 12 bytes")] + fn framing_refuses_a_wrong_length_iv() { + let _ = header(&[0u8; 11]); + } +} diff --git a/runtime/device-seal/src/identity.rs b/runtime/device-seal/src/identity.rs new file mode 100644 index 00000000..86128905 --- /dev/null +++ b/runtime/device-seal/src/identity.rs @@ -0,0 +1,110 @@ +//! THE DEVICE'S SIGNING HANDLES — identity-keys.ts, posture `platform` +//! (PERSISTENCE.md, "Sealing": "Device signing identity"). +//! +//! Non-extractable Ed25519, minted by the platform, persisted as handles +//! through the namespace's add-if-absent slot, NEVER passphrase-derived: +//! a wrapped seed is offline-guessable at passphrase strength, while a +//! non-extractable handle cannot leave the profile at all. +//! +//! WHO CHECKS WHAT, and why it is split that way. +//! +//! The usability predicate for a stored entry is the HOST'S, in full — +//! identity-keys.ts `usableIdentity`: both halves the right type and +//! algorithm, the private half non-extractable and able to `sign`, the +//! public half able to `verify` (world.wit, `namespace`'s +//! "VALIDATE-ON-LOAD IS THE HOST'S" paragraph). It has to live there +//! because `put-identity`'s add-if-absent transaction applies it to the +//! entry it finds, and a transaction cannot call back into a component. +//! `fromCryptoKey` alone is NOT that predicate: it refuses the wrong +//! type, algorithm and usages, but never looks at `extractable`. +//! +//! We re-check exactly ONE bit of it — [`refuse_extractable`] — on every +//! pair we receive, from either door. It is the bit whose failure is +//! silent and expensive: a codec bug on the host side would hand us a +//! signing key whose material can be read back, and this module would go +//! on using it as though the material had never been readable. Checking +//! it here turns that into a loud `unsupported` at the seam instead of a +//! device identity that is quietly a bearer secret. The rest of the +//! predicate fails visibly on first use and is the host's to own. + +use polymorph_webcrypto_guest::{ed25519, SigningKey, SigningKeyOptions, VerifyingKey}; + +use crate::ladder::{platform, Res}; +use crate::polyvisor::device_seal::namespace; +use crate::polyvisor::device_seal::types::IdentitySlot; +use crate::records::Refusal; + +/// Mint a fresh device identity. THE PRIVATE HALF IS NON-EXTRACTABLE — +/// this is the only place that decides it (identity-keys.ts:118-127). +async fn mint() -> Res<(SigningKey, VerifyingKey)> { + ed25519::generate_key(SigningKeyOptions { + sign: true, + extractable: false, + }) + .await + .map_err(platform) +} + +/// Refuse a pair whose signing half can export its material. +/// +/// A stored signing key PROMISES material that was never readable; a +/// readable one is a bearer secret wearing a handle's costume, and every +/// later loader — and every signature made under it — would inherit the +/// lie. `unsupported` is the WIT's code for a handle with the wrong shape +/// (world.wit's `namespace` validate-on-load paragraph and +/// `identity.load-or-mint`). +fn refuse_extractable(pair: (SigningKey, VerifyingKey)) -> Res<(SigningKey, VerifyingKey)> { + if pair.0.extractable() { + return Err(Refusal::extractable_identity()); + } + Ok(pair) +} + +fn lift(pair: (RawSigning, RawVerifying)) -> Res<(SigningKey, VerifyingKey)> { + refuse_extractable((SigningKey::from_raw(pair.0), VerifyingKey::from_raw(pair.1))) +} + +type RawSigning = polymorph_webcrypto_guest::bindings::signature::SigningKey; +type RawVerifying = polymorph_webcrypto_guest::bindings::signature::VerifyingKey; + +/// CREATE-OR-LOAD, RACE-FREE (identity-keys.ts `loadOrMintIdentity`, +/// 199-224). +/// +/// THE RETURNED PAIR IS WHAT IS STORED, NOT THE LOCAL MINT. Two workers +/// attaching to one device both want the identity to exist; a +/// read-then-write would mint two keys and let the later write silently +/// replace the identity the earlier one had already begun signing with — +/// signatures under a key nothing can produce again. `put-identity` is +/// ADD-IF-ABSENT and returns what is stored afterwards +/// (world.wit:227-233), so the loser's candidate is simply dropped and +/// both callers agree on one identity. Returning the candidate here +/// instead would reintroduce exactly the bug the slot exists to prevent. +/// +/// BOTH DOORS ARE CHECKED. `put-identity` can return the RACE WINNER'S +/// pair rather than ours, so its result is as much someone else's key as +/// `get-identity`'s is — checking only the read path would leave the +/// interesting case unchecked. +pub async fn load_or_mint(slot: IdentitySlot) -> Res<(SigningKey, VerifyingKey)> { + if let Some(pair) = load(slot).await? { + return Ok(pair); + } + let (signing, verifying) = mint().await?; + lift(namespace::put_identity(slot, signing.as_raw(), verifying.as_raw()).await) +} + +/// The slot's pair if one is stored and usable; `none` otherwise +/// (identity-keys.ts `loadIdentity`). Most of "usable" is the host's +/// judgement; the extractability bit is re-checked here — see the module +/// header. +pub async fn load(slot: IdentitySlot) -> Res> { + match namespace::get_identity(slot).await { + Some(pair) => lift(pair).map(Some), + None => Ok(None), + } +} + +/// Forget one identity. +pub async fn delete(slot: IdentitySlot) -> Res<()> { + namespace::delete_identity(slot).await; + Ok(()) +} diff --git a/runtime/device-seal/src/ladder.rs b/runtime/device-seal/src/ladder.rs new file mode 100644 index 00000000..4097f02a --- /dev/null +++ b/runtime/device-seal/src/ladder.rs @@ -0,0 +1,268 @@ +//! THE KEK LADDER AND THE DEK — seal.ts's ceremonies over the platform's +//! WebCrypto, reached through `polymorph:webcrypto`. +//! +//! Two rules govern every function here and are worth restating because +//! they are the ones a port gets wrong (world.wit:218-226): +//! +//! - THE DEK IS BORN EXTRACTABLE AND PARKED NON-EXTRACTABLE. `wrap` needs +//! a key whose material can be serialized, so the mint and every +//! re-wrap hold a wrappable DEK for the length of the ceremony and no +//! longer. [`wrappable_dek`] and [`wrappable_dek_from_platform`] are +//! the only two places one exists, and neither result is ever parked. +//! - THE SINGLE WRITE LANDS AFTER EVERY FALLIBLE STEP. A ceremony that +//! fails part-way leaves the namespace exactly as it was. +//! +//! ERROR MAPPING, stated once. An AES-KW unwrap that fails under a +//! passphrase KEK is `wrong-passphrase`; under a PRF KEK it is +//! `wrong-passkey`; under the platform KEK it is `tampered` — three +//! different facts about one indistinguishable event, told apart by which +//! door was tried, never by anything the platform reported. A record that +//! fails shape validation is `tampered`. Everything else the platform +//! declines is `unsupported`, carrying the platform's own sentence and +//! never key material. +//! +//! EVERY REFUSAL CARRIES ITS SENTENCE, built by a named constructor on +//! [`Refusal`] (`records.rs`) that cites the seal.ts line it is ported +//! from. The visor renders these, so the site that knows which refusal +//! this is is the site that states it. + +use polymorph_webcrypto_guest::aes_gcm::AesVariant; +use polymorph_webcrypto_guest::{ + aes_gcm, aes_kw, pbkdf2, pbkdf2_sha2, Aead, AeadKeyOptions, DeriveOptions, Error, KwKey, + KwKeyOptions, +}; + +use crate::polyvisor::device_seal::namespace; +use crate::polyvisor::device_seal::types::PassphraseOrigin; +use crate::records::{self, Refusal, PBKDF2_ITERATIONS, SALT_BYTES}; + +pub type Res = Result; + +/// The platform declined an operation. Carries its sentence; the wrapper +/// crate's `Display` never renders key material. +pub fn platform(err: Error) -> Refusal { + Refusal::platform(err.to_string()) +} + +/// `len` bytes from the PLATFORM'S CSPRNG (world.wit:330-332). Never an +/// in-guest generator: every salt, IV and nonce this component writes +/// comes through here. +pub fn random(len: usize) -> Vec { + crate::wasi::random::random::get_random_bytes(len as u64) +} + +// --- the ladder's two primitives -------------------------------------------- + +/// AES-KW, not AES-GCM, for both wraps (seal.ts:233-243). It is +/// deterministic, so a wrap needs no IV beside it and no IV-reuse hazard +/// exists across re-wraps of one key; and RFC 3394's integrity check +/// value is what turns a wrong passphrase into a CLEAN REFUSAL — the +/// unwrap fails inside the platform and no partial key ever exists. +/// +/// The KEK is non-extractable and may only wrap and unwrap: it exists to +/// hold the DEK and cannot encrypt data. +pub async fn kek_from_passphrase(passphrase: &str, salt: &[u8], iterations: u32) -> Res { + let password = pbkdf2::import_password( + passphrase.as_bytes().to_vec(), + DeriveOptions { + // The derivation mints a key and never yields raw bits. + derive_bits: false, + derive_key: true, + }, + ) + .await + .map_err(platform)?; + let input = pbkdf2_sha2::prepare( + polymorph_webcrypto_guest::sha2::Sha2Variant::Sha256, + &password, + salt.to_vec(), + iterations, + ) + .await + .map_err(platform)?; + aes_kw::derive_key( + AesVariant::Aes256, + &input, + KwKeyOptions { + wrap: true, + unwrap: true, + extractable: false, + }, + ) + .await + .map_err(platform) +} + +/// Serialize the DEK's material and encrypt it under `kek` (seal.ts +/// `wrapDek`). Only ever called with a wrappable DEK. +pub async fn wrap_dek(dek: &Aead, kek: &KwKey) -> Res> { + let input = dek.to_wrap_input_raw().await.map_err(platform)?; + kek.wrap(input).await.map_err(platform) +} + +/// Recover the DEK from `wrapped` (seal.ts `unwrapDek`). +/// +/// `extractable` is the whole distinction this module turns on: `false` +/// for the handle that gets parked, `true` for a ceremony's local. The +/// usages are the DEK's own — seal and open — and never wrap: nothing +/// wraps *under* the DEK. +/// +/// Returns the platform's error unmapped, because WHICH refusal a failed +/// unwrap is depends on which door was tried, and only the caller knows. +pub async fn unwrap_dek( + wrapped: &[u8], + kek: &KwKey, + extractable: bool, +) -> Result { + let input = kek.unwrap(wrapped.to_vec()).await?; + aes_gcm::unwrap_key_raw( + AesVariant::Aes256, + input, + AeadKeyOptions { + seal: true, + open: true, + wrap: false, + unwrap: false, + extractable, + }, + ) + .await +} + +/// A fresh AES-GCM-256 DEK, born EXTRACTABLE so the mint's own wrap can +/// serialize it (seal.ts:338). The handle that survives the ceremony is +/// the re-unwrapped non-extractable one, never this. +pub async fn generate_dek() -> Res { + aes_gcm::generate_key( + AesVariant::Aes256, + AeadKeyOptions { + seal: true, + open: true, + wrap: false, + unwrap: false, + extractable: true, + }, + ) + .await + .map_err(platform) +} + +// --- the wrappable DEK, and only here --------------------------------------- + +/// THE ONE PLACE A PASSPHRASE-AUTHORIZED WRAPPABLE DEK EXISTS (seal.ts +/// `wrappableDek`, 288-313). A local of the ceremony that needs it, +/// dropped when the ceremony returns, and never parked. +pub async fn wrappable_dek(passphrase: &str) -> Res { + let rec = namespace::get_passphrase_wrap() + .await + .ok_or_else(Refusal::no_passphrase_rung)?; + records::validate_passphrase_wrap(rec.iterations, &rec.salt, &rec.wrapped)?; + let kek = kek_from_passphrase(passphrase, &rec.salt, rec.iterations).await?; + unwrap_dek(&rec.wrapped, &kek, true) + .await + .map_err(|_| Refusal::wrong_passphrase()) +} + +/// The platform rung's [`wrappable_dek`] (seal.ts +/// `wrappableDekFromPlatform`, 505-533). Refuses `no-rung` when EITHER +/// half is missing — unlike `unseal-from-platform`, which reports the +/// same state as a plain `ok(false)`. +pub async fn wrappable_dek_from_platform() -> Res { + let (rec, kek) = platform_rung().await?; + let (rec, kek) = match (rec, kek) { + (Some(rec), Some(kek)) => (rec, kek), + _ => return Err(Refusal::no_platform_rung()), + }; + unwrap_dek(&rec.wrapped, &kek, true) + .await + .map_err(|_| Refusal::platform_wrap_did_not_open()) +} + +/// Read the platform wrap and its key, VALIDATING BOTH ON LOAD. +/// +/// IndexedDB is writable by anything else on this origin, so a stored key +/// is untrusted input on the way back in: a planted EXTRACTABLE key here +/// would be an attacker's handle we then used to unwrap the DEK +/// (seal.ts:546-552). It is refused as `tampered` rather than coerced. +/// A key that cannot unwrap is refused for the same reason — the ceremony +/// would fail at the operation anyway, and a typed refusal beats the +/// platform's prose. +/// +/// `Ok((None, _))` or `Ok((_, None))` is "no platform rung", which is the +/// caller's to interpret. +#[allow(clippy::type_complexity)] +async fn platform_rung() -> Res<(Option, Option)> { + let rec = namespace::get_platform_wrap().await; + let kek = namespace::get_platform_kek().await.map(KwKey::from_raw); + if let Some(rec) = &rec { + records::validate_platform_wrap(&rec.wrapped)?; + } + if let Some(kek) = &kek { + if kek.extractable() || !kek.can_unwrap() { + return Err(Refusal::platform_kek_unusable()); + } + } + Ok((rec, kek)) +} + +/// VALIDATE THE CROSSED PRF KEK BEFORE USING IT. +/// +/// CONTRACT: seal.ts `requirePrfKek` (588-595) refuses with `tampered`; +/// world.wit:303-304 spells this refusal `unsupported` ("Refuses a `kek` +/// that is extractable or lacks wrap+unwrap as `unsupported`"), and +/// world.wit:65-67 names exactly this case as `unsupported`'s reason for +/// existing ("a KEK handle with the wrong shape"). The WIT is the pinned +/// contract, so `unsupported` it is; the TypeScript's code differs. Its +/// SENTENCE is seal.ts's, unchanged. +/// +/// The key arrived over the port from the page, which anything on this +/// origin may hold. An extractable one is not the handle this ceremony +/// was designed around: wrapping the device's DEK under something whose +/// material can be read back would undo the rung. AES-KW is guaranteed by +/// the resource type — a `kw-key` is nothing else. +pub fn require_prf_kek(kek: &KwKey) -> Res<()> { + if kek.extractable() || !kek.can_wrap() || !kek.can_unwrap() { + return Err(Refusal::prf_kek_unusable()); + } + Ok(()) +} + +// --- record helpers ---------------------------------------------------------- + +/// A `wrap:passphrase` record for a fresh derivation, with the parameters +/// THIS version writes (seal.ts:340-347). +pub fn passphrase_record( + salt: Vec, + wrapped: Vec, + origin: PassphraseOrigin, +) -> namespace::PassphraseWrap { + namespace::PassphraseWrap { + iterations: PBKDF2_ITERATIONS, + salt, + wrapped, + origin: Some(origin), + } +} + +/// A fresh 16-byte salt from the platform CSPRNG. +pub fn fresh_salt() -> Vec { + random(SALT_BYTES) +} + +/// The validated `wrap:prf` reader — the ONE reader both PRF ceremonies +/// go through, so a planted record is refused identically whether the +/// page is about to run an assertion or the worker is about to unwrap +/// (seal.ts `readPrfWrap`, 628-649). +pub async fn read_prf_wrap() -> Res> { + let Some(rec) = namespace::get_prf_wrap().await else { + return Ok(None); + }; + records::validate_prf_wrap( + &rec.credential_id, + &rec.rp_id, + &rec.prf_input, + &rec.hkdf_salt, + &rec.wrapped, + )?; + Ok(Some(rec)) +} diff --git a/runtime/device-seal/src/lib.rs b/runtime/device-seal/src/lib.rs new file mode 100644 index 00000000..4eab85ed --- /dev/null +++ b/runtime/device-seal/src/lib.rs @@ -0,0 +1,74 @@ +//! THE DEVICE SEAL AS A COMPONENT — the Rust half of +//! `polyvisor:device-seal@0.1.0` (wit/world.wit). +//! +//! seal.ts's KEK ladder and DEK, sealed-fs.ts's per-file format, and +//! identity-keys.ts's signing handles, moved behind a component boundary. +//! What the boundary buys is that THE UNSEALED DEK EXISTS NOWHERE IN +//! JAVASCRIPT: the worker holds a component and asks it to seal and open +//! bytes, with no handle to export. The parked DEK lives in +//! [`state`]'s thread-local and dies with the instance, exactly as +//! dropping the `CryptoKey` handle re-sealed the device before. +//! +//! MODULE SPLIT, and the reason for it. `records` and `file_format` are +//! PURE: plain data in, refusals out, no `bindings` in sight, compiled +//! and tested natively by `cargo test`. That is where "absent origin +//! means generated", "a 31-byte PRF salt is tampering", and the PMSEALv1 +//! layout live, and it is the WIT's claim that those rules become +//! `cargo test` rather than a browser matrix (world.wit:23-26) being +//! made good. Everything that touches the platform is `wasm32`-only. + +pub mod file_format; +pub mod records; + +// BINDINGS REUSE. `polymorph-webcrypto-guest` already binds the whole +// `polymorph:webcrypto` surface, and our world NAMES its types — +// `kw-key` crosses `namespace` and `seal`, `signing-key`/`verifying-key` +// cross `namespace` and `identity`. Every `polymorph:webcrypto` +// interface in this world's transitive resolution must therefore be +// remapped onto that crate's `bindings::*`; binding one a second time +// here would yield a nominally different resource type that no wrapper +// accepts (the crate says so at rust/guest/src/lib.rs:14-20, and +// engine/guest/src/lib.rs:18-36 is this repo's precedent). +// +// The list is the closure of the world's thirteen webcrypto imports over +// their `use` statements: `types` and `wrapping` under everything; +// `derivation` under the KDFs; `digest` under `sha2`; `aes` for +// `aes-variant`; `aead`/`key-wrap`/`signature` for the resources their +// algorithm interfaces mint. +// +// `wasi:random/random` is deliberately NOT remapped: it is ours to bind, +// and it is the platform CSPRNG every salt, IV and nonce here comes from. +#[cfg(target_arch = "wasm32")] +wit_bindgen::generate!({ + path: "wit", + world: "device-seal", + generate_all, + with: { + "polymorph:webcrypto/types@0.1.0": polymorph_webcrypto_guest::bindings::types, + "polymorph:webcrypto/wrapping@0.1.0": polymorph_webcrypto_guest::bindings::wrapping, + "polymorph:webcrypto/derivation@0.1.0": polymorph_webcrypto_guest::bindings::derivation, + "polymorph:webcrypto/digest@0.1.0": polymorph_webcrypto_guest::bindings::digest, + "polymorph:webcrypto/sha2@0.1.0": polymorph_webcrypto_guest::bindings::sha2, + "polymorph:webcrypto/pbkdf2@0.1.0": polymorph_webcrypto_guest::bindings::pbkdf2, + "polymorph:webcrypto/pbkdf2-sha2@0.1.0": polymorph_webcrypto_guest::bindings::pbkdf2_sha2, + "polymorph:webcrypto/hkdf@0.1.0": polymorph_webcrypto_guest::bindings::hkdf, + "polymorph:webcrypto/hkdf-sha2@0.1.0": polymorph_webcrypto_guest::bindings::hkdf_sha2, + "polymorph:webcrypto/key-wrap@0.1.0": polymorph_webcrypto_guest::bindings::key_wrap, + "polymorph:webcrypto/aes@0.1.0": polymorph_webcrypto_guest::bindings::aes, + "polymorph:webcrypto/aes-kw@0.1.0": polymorph_webcrypto_guest::bindings::aes_kw, + "polymorph:webcrypto/aead@0.1.0": polymorph_webcrypto_guest::bindings::aead, + "polymorph:webcrypto/aes-gcm@0.1.0": polymorph_webcrypto_guest::bindings::aes_gcm, + "polymorph:webcrypto/signature@0.1.0": polymorph_webcrypto_guest::bindings::signature, + "polymorph:webcrypto/ed25519-sign@0.1.0": polymorph_webcrypto_guest::bindings::ed25519_sign, + "polymorph:webcrypto/ed25519-verify@0.1.0": polymorph_webcrypto_guest::bindings::ed25519_verify, + }, +}); + +#[cfg(target_arch = "wasm32")] +mod component; +#[cfg(target_arch = "wasm32")] +mod identity; +#[cfg(target_arch = "wasm32")] +mod ladder; +#[cfg(target_arch = "wasm32")] +mod state; diff --git a/runtime/device-seal/src/records.rs b/runtime/device-seal/src/records.rs new file mode 100644 index 00000000..486a3627 --- /dev/null +++ b/runtime/device-seal/src/records.rs @@ -0,0 +1,773 @@ +//! THE RULES THE LADDER ENFORCES ON RECORDS IT READS BACK, as pure +//! functions over plain data. +//! +//! Everything here runs natively under `cargo test`. Nothing here names +//! `bindings`: the point of the split is that "absent origin means +//! generated", "a salt that is not 16 bytes is tampering", "an empty +//! passphrase is not a rung" stop being assertions in a browser matrix +//! and become unit tests. +//! +//! The `seal` store rests UNSEALED by design (seal.ts:75-81), so anything +//! else on the origin can write it. A record read back out is therefore +//! untrusted input, and validating its shape before handing it to a +//! ceremony is the whole reason these functions exist. + +/// Why the sealing layer refused, as the closed set the WIT +/// `types.seal-code` enum spells (seal.ts `SealError.code`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Code { + WrongPassphrase, + WrongPasskey, + NoRung, + AlreadySealed, + Tampered, + /// A request refused on principle — an empty passphrase, a KEK handle + /// with the wrong shape — and also a platform operation that + /// declined. + Unsupported, +} + +/// A refusal: the code a caller branches on, and THE SENTENCE THE VISOR +/// SHOWS (WIT `types.seal-error`). +/// +/// THE SENTENCE IS THIS COMPONENT'S, not the host's. Only the code that +/// knows which refusal this is can say which refusal this is; a host +/// inventing prose from a bare code is how a sheet ends up telling a user +/// "an error occurred" about a wrong passphrase. seal.ts had eleven +/// distinct sentences and the sheets rendered them; they are ported here +/// verbatim, cited per constructor. +/// +/// FRAMEWORK VOICE, and the two standing rules: never key material, and +/// never user-typed text. The single interpolation anywhere below is the +/// `sealed` store's KEY NAME, which is a program-chosen string and was +/// already interpolated by seal.ts:829 — not something a person typed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub code: Code, + pub message: String, +} + +impl Refusal { + fn new(code: Code, message: impl Into) -> Self { + Refusal { + code, + message: message.into(), + } + } + + // --- no-rung ------------------------------------------------------- + + /// seal.ts:306, 373. + pub fn no_passphrase_rung() -> Self { + Self::new(Code::NoRung, "this device has no passphrase rung") + } + + /// seal.ts:733. + pub fn no_passkey_rung() -> Self { + Self::new(Code::NoRung, "this device has no passkey rung") + } + + /// seal.ts:517. + pub fn no_platform_rung() -> Self { + Self::new( + Code::NoRung, + "this device has no platform rung to re-key from", + ) + } + + /// seal.ts:692-695 — the two-authority refusal `enablePrf` makes when + /// neither door is open. + pub fn no_prf_authority() -> Self { + Self::new( + Code::NoRung, + "enrolling a passkey needs an authority: this device has no platform rung, \ + and no passphrase was offered", + ) + } + + /// Nothing is parked: the component is sealed (WIT `sealed`'s header). + /// seal.ts has no precedent — there the DEK was the caller's to hold, + /// so "no DEK" was unrepresentable rather than refusable. + pub fn device_sealed() -> Self { + Self::new(Code::NoRung, "this device is sealed") + } + + // --- already-sealed ------------------------------------------------ + + /// seal.ts:333. + pub fn already_sealed() -> Self { + Self::new( + Code::AlreadySealed, + "this device already has a passphrase rung", + ) + } + + // --- tampered ------------------------------------------------------ + + /// seal.ts:531, 556. + pub fn platform_wrap_did_not_open() -> Self { + Self::new(Code::Tampered, "the platform wrap did not open") + } + + /// seal.ts:525, 551. + pub fn platform_kek_unusable() -> Self { + Self::new( + Code::Tampered, + "the persisted platform key is not a usable non-extractable AES-KW key", + ) + } + + /// seal.ts:647. + pub fn prf_record_unreadable() -> Self { + Self::new( + Code::Tampered, + "this device's passkey rung record is not readable", + ) + } + + /// A record that failed shape validation. `record` names the record in + /// the store's own vocabulary ("passphrase wrap", "platform wrap") and + /// is a literal at every call site — never anything read back. + pub fn record_unreadable(record: &str) -> Self { + Self::new(Code::Tampered, format!("the {record} record is not readable")) + } + + /// seal.ts:829. The key name is JSON-quoted exactly as + /// `JSON.stringify` renders it. + pub fn sealed_value_did_not_open(key: &str) -> Self { + Self::new( + Code::Tampered, + format!("the sealed value {} did not open", json_quote(key)), + ) + } + + /// The shape-validation sibling of [`Self::sealed_value_did_not_open`]: + /// the record is present and is not the shape this layer writes. + pub fn sealed_value_unreadable(key: &str) -> Self { + Self::new( + Code::Tampered, + format!("the sealed value {} is not readable", json_quote(key)), + ) + } + + /// sealed-fs.ts:156. That message is prefixed with the FILE NAME, + /// which does not cross this boundary — `open-file` takes bytes — so + /// "this file" stands in for the subject the prefix supplied. + pub fn not_a_sealed_file() -> Self { + Self::new(Code::Tampered, "this file is not a sealed file") + } + + /// sealed-fs.ts:165, same substitution as + /// [`Self::not_a_sealed_file`]. A wrong DEK and altered bytes are the + /// same event to GCM and are reported as one. + pub fn file_did_not_open() -> Self { + Self::new( + Code::Tampered, + "this file did not open under this device key (wrong key or altered bytes)", + ) + } + + // --- unsupported --------------------------------------------------- + + /// seal.ts:359. + pub fn empty_passphrase() -> Self { + Self::new( + Code::Unsupported, + "an empty passphrase cannot seal a device", + ) + } + + /// seal.ts:590-592 (`requirePrfKek`). The sentence is seal.ts's; the + /// CODE is the WIT's `unsupported` rather than the TypeScript's + /// `tampered` — world.wit:66 names this exact case as `unsupported`'s + /// reason for existing. + pub fn prf_kek_unusable() -> Self { + Self::new( + Code::Unsupported, + "the passkey KEK handed to this ceremony is not a usable non-extractable AES-KW key", + ) + } + + /// The extractability re-check on a pair crossing the identity seam. + pub fn extractable_identity() -> Self { + Self::new( + Code::Unsupported, + "the stored device identity is extractable and was refused", + ) + } + + /// The platform declined. Carries the platform's own sentence, which + /// is prose about an algorithm or a keystore and never key material. + pub fn platform(message: impl Into) -> Self { + Self::new(Code::Unsupported, message) + } + + // --- the two one-bit refusals -------------------------------------- + + /// seal.ts:311, 380. + pub fn wrong_passphrase() -> Self { + Self::new( + Code::WrongPassphrase, + "the passphrase did not open this device", + ) + } + + /// seal.ts:740. + pub fn wrong_passkey() -> Self { + Self::new(Code::WrongPasskey, "that passkey did not open this device") + } +} + +/// Render `s` as a JSON string literal, as `JSON.stringify` does — the +/// quoting seal.ts:829 applied to the key name. +fn json_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for ch in s.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Whether anybody knows the passphrase (seal.ts `PassphraseWrap.origin`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + User, + Generated, +} + +// --- the recorded parameters ------------------------------------------------- + +/// PBKDF2-HMAC-SHA-256 work factor written by THIS version. Read paths +/// take the count from the record instead, so raising this floor later +/// does not orphan existing devices (seal.ts:88-94). +pub const PBKDF2_ITERATIONS: u32 = 600_000; +/// 16 fresh random bytes per wrap (seal.ts:94; NIST SP 800-132's floor). +pub const SALT_BYTES: usize = 16; +/// AES-GCM's nonce, 96 bits, fresh per write (seal.ts:766-770). +pub const IV_BYTES: usize = 12; +/// Both PRF salts — the PRF input and HKDF's salt — are 32 bytes +/// (seal.ts:636-649, PERSISTENCE.md "The derivation, ruled"). +pub const PRF_SALT_BYTES: usize = 32; + +// --- the rules --------------------------------------------------------------- + +/// ABSENT ORIGIN READS AS `generated` (seal.ts:120-125). The failure +/// modes are not symmetric: reading an unmarked rung as reachable risks +/// deleting the last door on a device whose passphrase nobody knows, +/// while reading it as unreachable costs one ceremony nobody needed. +pub fn origin_or_generated(origin: Option) -> Origin { + origin.unwrap_or(Origin::Generated) +} + +/// Whether a stored passphrase rung is one a PERSON can walk through — +/// the bit `seal-state.user-passphrase` reports and the bit a ceremony +/// that deletes the platform wrap has to consult. +pub fn is_user_passphrase(origin: Option) -> bool { + origin_or_generated(origin) == Origin::User +} + +/// An empty passphrase is the absence of a rung wearing a rung's costume; +/// refuse it at the door rather than derive a KEK anyone can reproduce +/// (seal.ts `requirePassphrase`, 354-361). +pub fn require_passphrase(passphrase: &str) -> Result<(), Refusal> { + if passphrase.is_empty() { + return Err(Refusal::empty_passphrase()); + } + Ok(()) +} + +/// Shape-check a `wrap:passphrase` record before deriving anything from +/// it. +/// +/// CONTRACT: seal.ts's read path (`unsealWithPassphrase`, `wrappableDek`) +/// validates NOTHING here — it feeds `rec.salt` and `rec.iterations` +/// straight to PBKDF2. The WIT pins the field ("16 bytes, fresh per +/// wrap", world.wit:148-149) and the dispatch requires a wrong-length +/// salt to refuse as `tampered`, so this is stricter than the TypeScript. +/// It cannot orphan a real device: every record seal.ts ever wrote +/// carries a 16-byte salt and a nonzero count. +pub fn validate_passphrase_wrap( + iterations: u32, + salt: &[u8], + wrapped: &[u8], +) -> Result<(), Refusal> { + if salt.len() != SALT_BYTES || iterations == 0 || wrapped.is_empty() { + return Err(Refusal::record_unreadable("passphrase wrap")); + } + Ok(()) +} + +/// Shape-check a `wrap:platform` record. +/// +/// CONTRACT: as above, seal.ts checks only that the record exists; the +/// wrapped bytes being non-empty is the one claim worth making before +/// asking the platform to unwrap them. +pub fn validate_platform_wrap(wrapped: &[u8]) -> Result<(), Refusal> { + if wrapped.is_empty() { + return Err(Refusal::record_unreadable("platform wrap")); + } + Ok(()) +} + +/// Shape-check a `wrap:prf` record — seal.ts `readPrfWrap` (636-649), +/// field for field. +/// +/// The salts are pinned at the length this construction writes because a +/// planted one-byte PRF input would otherwise reach an authenticator +/// ceremony before anything refused it. `v` and `kdf` are checked on the +/// host side of the seam (world.wit:162-165 fixes `kdf` and reads another +/// tag as `none`), so they do not appear here. +pub fn validate_prf_wrap( + credential_id: &[u8], + rp_id: &str, + prf_input: &[u8], + hkdf_salt: &[u8], + wrapped: &[u8], +) -> Result<(), Refusal> { + let ok = !credential_id.is_empty() + && !rp_id.is_empty() + && prf_input.len() == PRF_SALT_BYTES + && hkdf_salt.len() == PRF_SALT_BYTES + && !wrapped.is_empty(); + if !ok { + return Err(Refusal::prf_record_unreadable()); + } + Ok(()) +} + +/// Shape-check a `sealed` store value before asking GCM to open it. +/// +/// CONTRACT: the WIT pins the IV at 12 bytes (world.wit:177-178); +/// seal.ts passes whatever was stored. A wrong-length IV is a record +/// nothing in this repo wrote, so it is refused as `tampered` rather than +/// handed to the platform — the same fact GCM would report a moment +/// later, reported at the shape check instead. +pub fn validate_sealed_value(key: &str, iv: &[u8], ct: &[u8]) -> Result<(), Refusal> { + if iv.len() != IV_BYTES || ct.is_empty() { + return Err(Refusal::sealed_value_unreadable(key)); + } + Ok(()) +} + +/// The additional data binding a sealed value to its key name +/// (seal.ts:778-786, 837). Not secret — it is the IndexedDB key, in the +/// clear — but binding it stops an attacker with write access to the +/// namespace moving a valid value from one name to another, a swap that +/// would otherwise be undetectable because every value rests under one +/// DEK. +pub fn sealed_aad(key: &str) -> &[u8] { + key.as_bytes() +} + +/// WHICH RUNGS THIS DEVICE HAS (seal.ts `sealState`, 214-226), asked +/// without opening anything. +/// +/// `until_reseal` follows the PLATFORM WRAP alone, not the wrap-and-key +/// pair (seal.ts:223): a wrap whose key has gone missing still reports +/// the rung, and `unseal-from-platform` is where that mismatch surfaces. +pub fn seal_state( + passphrase: Option>, + platform_wrap: bool, + prf_wrap: bool, +) -> (bool, bool, bool, bool) { + let has_passphrase = passphrase.is_some(); + let user = passphrase.is_some_and(is_user_passphrase); + (has_passphrase, user, platform_wrap, prf_wrap) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- the refusal sentences ------------------------------------------ + // + // The visor renders these, so an empty one is a blank sheet and a + // wrong code is a sheet offering the wrong door. Both are checked for + // every constructor, together, because they are one fact. + + /// Every constructor, paired with the code it must report. Adding a + /// constructor without adding it here leaves it unchecked, so the + /// list is the inventory as well as the test. + fn every_refusal() -> Vec<(&'static str, Refusal, Code)> { + vec![ + ("no_passphrase_rung", Refusal::no_passphrase_rung(), Code::NoRung), + ("no_passkey_rung", Refusal::no_passkey_rung(), Code::NoRung), + ("no_platform_rung", Refusal::no_platform_rung(), Code::NoRung), + ("no_prf_authority", Refusal::no_prf_authority(), Code::NoRung), + ("device_sealed", Refusal::device_sealed(), Code::NoRung), + ("already_sealed", Refusal::already_sealed(), Code::AlreadySealed), + ( + "platform_wrap_did_not_open", + Refusal::platform_wrap_did_not_open(), + Code::Tampered, + ), + ( + "platform_kek_unusable", + Refusal::platform_kek_unusable(), + Code::Tampered, + ), + ( + "prf_record_unreadable", + Refusal::prf_record_unreadable(), + Code::Tampered, + ), + ( + "record_unreadable", + Refusal::record_unreadable("passphrase wrap"), + Code::Tampered, + ), + ( + "sealed_value_did_not_open", + Refusal::sealed_value_did_not_open("keyhive/archive"), + Code::Tampered, + ), + ( + "sealed_value_unreadable", + Refusal::sealed_value_unreadable("keyhive/archive"), + Code::Tampered, + ), + ("not_a_sealed_file", Refusal::not_a_sealed_file(), Code::Tampered), + ("file_did_not_open", Refusal::file_did_not_open(), Code::Tampered), + ("empty_passphrase", Refusal::empty_passphrase(), Code::Unsupported), + ("prf_kek_unusable", Refusal::prf_kek_unusable(), Code::Unsupported), + ( + "extractable_identity", + Refusal::extractable_identity(), + Code::Unsupported, + ), + ( + "platform", + Refusal::platform("the platform declined to derive a key"), + Code::Unsupported, + ), + ("wrong_passphrase", Refusal::wrong_passphrase(), Code::WrongPassphrase), + ("wrong_passkey", Refusal::wrong_passkey(), Code::WrongPasskey), + ] + } + + #[test] + fn every_refusal_carries_a_sentence_and_the_code_its_site_means() { + for (name, refusal, code) in every_refusal() { + assert_eq!(refusal.code, code, "{name} reports the wrong code"); + assert!( + !refusal.message.trim().is_empty(), + "{name} has no sentence for the visor to render" + ); + } + } + + #[test] + fn the_sentences_are_framework_voice_not_shouting_or_prefixes() { + // A sentence is rendered as-is in a sheet: no trailing + // punctuation to double up, no leading capital to fight the + // surrounding copy, no stray whitespace, and no `SealError:`-style + // prefix left over from a thrown exception. + for (name, refusal, _) in every_refusal() { + let m = &refusal.message; + assert_eq!(m.trim(), m, "{name} has stray whitespace"); + assert!(!m.ends_with('.'), "{name} ends with a full stop"); + assert!( + !m.contains("Error") && !m.contains("error:"), + "{name} carries an exception prefix" + ); + assert!( + m.chars().next().is_some_and(|c| !c.is_uppercase()), + "{name} starts with a capital" + ); + } + } + + #[test] + fn the_ported_sentences_are_seal_ts_word_for_word() { + // The visor's copy is these strings; a paraphrase here is a + // silent copy change. Cited sites are in the constructors. + assert_eq!( + Refusal::already_sealed().message, + "this device already has a passphrase rung" + ); + assert_eq!( + Refusal::no_passphrase_rung().message, + "this device has no passphrase rung" + ); + assert_eq!( + Refusal::no_passkey_rung().message, + "this device has no passkey rung" + ); + assert_eq!( + Refusal::no_platform_rung().message, + "this device has no platform rung to re-key from" + ); + assert_eq!( + Refusal::empty_passphrase().message, + "an empty passphrase cannot seal a device" + ); + assert_eq!( + Refusal::wrong_passphrase().message, + "the passphrase did not open this device" + ); + assert_eq!( + Refusal::wrong_passkey().message, + "that passkey did not open this device" + ); + assert_eq!( + Refusal::platform_wrap_did_not_open().message, + "the platform wrap did not open" + ); + assert_eq!( + Refusal::platform_kek_unusable().message, + "the persisted platform key is not a usable non-extractable AES-KW key" + ); + assert_eq!( + Refusal::prf_record_unreadable().message, + "this device's passkey rung record is not readable" + ); + assert_eq!( + Refusal::prf_kek_unusable().message, + "the passkey KEK handed to this ceremony is not a usable non-extractable AES-KW key" + ); + } + + #[test] + fn the_sealed_key_name_is_json_quoted_as_seal_ts_quoted_it() { + // seal.ts:829 interpolated `JSON.stringify(key)`. The key is a + // program-chosen store key, never user-typed text. + assert_eq!( + Refusal::sealed_value_did_not_open("keyhive/archive").message, + r#"the sealed value "keyhive/archive" did not open"# + ); + assert_eq!( + Refusal::sealed_value_unreadable("visor/cache").message, + r#"the sealed value "visor/cache" is not readable"# + ); + } + + #[test] + fn json_quoting_escapes_what_json_stringify_escapes() { + assert_eq!(json_quote("plain"), r#""plain""#); + assert_eq!(json_quote(r#"a"b"#), r#""a\"b""#); + assert_eq!(json_quote(r"a\b"), r#""a\\b""#); + assert_eq!(json_quote("a\nb"), r#""a\nb""#); + assert_eq!(json_quote("a\u{1}b"), r#""a\u0001b""#); + } + + #[test] + fn a_named_record_refusal_names_the_record() { + assert_eq!( + Refusal::record_unreadable("platform wrap").message, + "the platform wrap record is not readable" + ); + } + + #[test] + fn a_platform_decline_carries_the_platforms_own_sentence() { + // The platform's prose is about an algorithm or a keystore; the + // component neither rewrites it nor invents one over it. + let refusal = Refusal::platform("aes-kw unwrap is not supported by this provider"); + assert_eq!(refusal.code, Code::Unsupported); + assert_eq!( + refusal.message, + "aes-kw unwrap is not supported by this provider" + ); + } + + #[test] + fn absent_origin_reads_as_generated() { + assert_eq!(origin_or_generated(None), Origin::Generated); + assert!(!is_user_passphrase(None)); + } + + #[test] + fn a_recorded_origin_is_taken_at_its_word() { + assert_eq!(origin_or_generated(Some(Origin::User)), Origin::User); + assert!(is_user_passphrase(Some(Origin::User))); + assert!(!is_user_passphrase(Some(Origin::Generated))); + } + + #[test] + fn refuses_an_empty_passphrase_as_unsupported() { + assert_eq!(require_passphrase(""), Err(Refusal::empty_passphrase())); + assert_eq!(require_passphrase(" "), Ok(())); + assert_eq!(require_passphrase("a passphrase"), Ok(())); + } + + #[test] + fn accepts_a_passphrase_wrap_of_the_shape_this_version_writes() { + // Obviously-synthetic stand-ins: an all-zero 16-byte salt and a + // 40-byte all-zero wrap (AES-KW of a 256-bit key is 40 bytes). + assert_eq!( + validate_passphrase_wrap(PBKDF2_ITERATIONS, &[0u8; SALT_BYTES], &[0u8; 40]), + Ok(()) + ); + } + + #[test] + fn refuses_a_record_whose_salt_is_not_16_bytes_as_tampered() { + for len in [0usize, 1, 8, 15, 17, 32] { + assert_eq!( + validate_passphrase_wrap(PBKDF2_ITERATIONS, &vec![0u8; len], &[0u8; 40]), + Err(Refusal::record_unreadable("passphrase wrap")), + "a {len}-byte salt was accepted" + ); + } + } + + #[test] + fn refuses_a_zero_iteration_count_and_an_empty_wrap_as_tampered() { + assert_eq!( + validate_passphrase_wrap(0, &[0u8; SALT_BYTES], &[0u8; 40]), + Err(Refusal::record_unreadable("passphrase wrap")) + ); + assert_eq!( + validate_passphrase_wrap(PBKDF2_ITERATIONS, &[0u8; SALT_BYTES], &[]), + Err(Refusal::record_unreadable("passphrase wrap")) + ); + } + + #[test] + fn reads_the_iteration_count_from_the_record_not_the_constant() { + // A device written under an older floor still validates: the + // count is recorded precisely so raising the constant does not + // orphan it. + assert_eq!( + validate_passphrase_wrap(100_000, &[0u8; SALT_BYTES], &[0u8; 40]), + Ok(()) + ); + } + + #[test] + fn refuses_an_empty_platform_wrap_as_tampered() { + assert_eq!(validate_platform_wrap(&[0u8; 40]), Ok(())); + assert_eq!( + validate_platform_wrap(&[]), + Err(Refusal::record_unreadable("platform wrap")) + ); + } + + /// The shape `enablePrf` writes, with obviously-synthetic salts. + fn good_prf() -> (Vec, String, Vec, Vec, Vec) { + ( + vec![1u8; 16], + "localhost".to_string(), + vec![0u8; PRF_SALT_BYTES], + vec![0u8; PRF_SALT_BYTES], + vec![0u8; 40], + ) + } + + #[test] + fn accepts_a_prf_record_of_the_shape_enrollment_writes() { + let (c, r, p, h, w) = good_prf(); + assert_eq!(validate_prf_wrap(&c, &r, &p, &h, &w), Ok(())); + } + + #[test] + fn refuses_a_prf_record_whose_salts_are_not_32_bytes_as_tampered() { + let (c, r, _, h, w) = good_prf(); + // A planted one-byte PRF input must refuse HERE, before an + // authenticator is ever asked to evaluate it. + for len in [0usize, 1, 31, 33] { + assert_eq!( + validate_prf_wrap(&c, &r, &vec![0u8; len], &h, &w), + Err(Refusal::prf_record_unreadable()), + "a {len}-byte prf input was accepted" + ); + } + let (c, r, p, _, w) = good_prf(); + for len in [0usize, 1, 31, 33] { + assert_eq!( + validate_prf_wrap(&c, &r, &p, &vec![0u8; len], &w), + Err(Refusal::prf_record_unreadable()), + "a {len}-byte hkdf salt was accepted" + ); + } + } + + #[test] + fn refuses_a_prf_record_missing_its_credential_rp_id_or_wrap() { + let (c, r, p, h, w) = good_prf(); + assert_eq!( + validate_prf_wrap(&[], &r, &p, &h, &w), + Err(Refusal::prf_record_unreadable()) + ); + assert_eq!( + validate_prf_wrap(&c, "", &p, &h, &w), + Err(Refusal::prf_record_unreadable()) + ); + assert_eq!( + validate_prf_wrap(&c, &r, &p, &h, &[]), + Err(Refusal::prf_record_unreadable()) + ); + } + + #[test] + fn a_prf_record_carries_no_origin_so_a_prf_rung_is_always_reachable() { + // The rule is structural: `validate_prf_wrap` has no origin + // parameter to consult, and `seal_state` reports `prf` from the + // record's presence alone. + let (_, _, _, _, _) = good_prf(); + let (_, _, _, prf) = seal_state(None, false, true); + assert!(prf); + } + + #[test] + fn refuses_a_sealed_value_whose_iv_is_not_12_bytes_as_tampered() { + assert_eq!( + validate_sealed_value("k", &[0u8; IV_BYTES], &[0u8; 17]), + Ok(()) + ); + for len in [0usize, 11, 13, 16] { + assert_eq!( + validate_sealed_value("k", &vec![0u8; len], &[0u8; 17]), + Err(Refusal::sealed_value_unreadable("k")), + "a {len}-byte iv was accepted" + ); + } + } + + #[test] + fn refuses_a_sealed_value_with_no_ciphertext_as_tampered() { + // Even an empty plaintext seals to at least a 16-byte tag, so an + // empty `ct` is a record nothing here wrote. + assert_eq!( + validate_sealed_value("k", &[0u8; IV_BYTES], &[]), + Err(Refusal::sealed_value_unreadable("k")) + ); + } + + #[test] + fn the_key_name_is_the_additional_data() { + assert_eq!(sealed_aad("keyhive/archive"), b"keyhive/archive"); + assert_eq!(sealed_aad(""), b""); + } + + #[test] + fn seal_state_reports_existence_and_reachability_separately() { + // A T0 device: a passphrase rung exists, nobody knows it. + let (passphrase, user, until_reseal, prf) = + seal_state(Some(Some(Origin::Generated)), true, false); + assert!(passphrase && !user && until_reseal && !prf); + + // The same device after `rekey-from-platform`. + let (passphrase, user, ..) = seal_state(Some(Some(Origin::User)), true, false); + assert!(passphrase && user); + + // An unmarked rung reads as unreachable. + let (passphrase, user, ..) = seal_state(Some(None), false, false); + assert!(passphrase && !user); + + // A device with nothing. + assert_eq!(seal_state(None, false, false), (false, false, false, false)); + } +} diff --git a/runtime/device-seal/src/state.rs b/runtime/device-seal/src/state.rs new file mode 100644 index 00000000..7af183e0 --- /dev/null +++ b/runtime/device-seal/src/state.rs @@ -0,0 +1,46 @@ +//! THE PARKED DEK. +//! +//! Where seal.ts handed a non-extractable `CryptoKey` back to the worker +//! for it to hold, this component parks it here (world.wit:210-215). +//! Dropping the component re-seals the device exactly as dropping the +//! handle did, and `forget` is the explicit form of the same thing. +//! +//! `Rc`, not a bare `Aead`, so a ceremony can take a counted reference +//! out of the cell and hold it across an `await` without holding the +//! `RefCell` borrow — the rule the async exports would otherwise break +//! the first time two of them overlapped. + +use std::cell::RefCell; +use std::rc::Rc; + +use polymorph_webcrypto_guest::Aead; + +thread_local! { + /// The DEK, non-extractable, for as long as this instance lives. + /// `None` means the component is sealed. + static PARKED: RefCell>> = const { RefCell::new(None) }; +} + +/// Park the DEK. THE PARKED HANDLE IS NEVER THE WRAPPABLE ONE +/// (world.wit:220-224): every caller here passes the key it re-unwrapped +/// `extractable: false`, never the ceremony's local. +pub fn park(dek: Aead) { + PARKED.with(|slot| *slot.borrow_mut() = Some(Rc::new(dek))); +} + +/// Drop the parked DEK. The namespace is untouched. +pub fn forget() { + PARKED.with(|slot| *slot.borrow_mut() = None); +} + +/// Whether a DEK is parked in this component. +pub fn unsealed() -> bool { + PARKED.with(|slot| slot.borrow().is_some()) +} + +/// A counted reference to the parked DEK, or `None` when the component is +/// sealed. The borrow ends inside this function, so the caller may hold +/// the result across an `await`. +pub fn dek() -> Option> { + PARKED.with(|slot| slot.borrow().clone()) +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/aes.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/aes.wit new file mode 100644 index 00000000..62301d4f --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/aes.wit @@ -0,0 +1,282 @@ +package polymorph:webcrypto@0.1.0; + +/// The AES family's shared parameterization. Interfaces minting AES-based +/// keys (`aes-gcm`, `aes-cbc`, …) declare their variant from +/// this one closed set (the `sha2` pattern: one definition per family). +interface aes { + /// The AES variants, distinguished by key size: AES closes this set, so + /// it is an enum, not a number. (Each algorithm interface names its + /// closed set of parameterizations `-variant`.) + enum aes-variant { + aes128, + /// Declined by every implementation of this package: every + /// minting path fails `error.unsupported`. The decline is a + /// portability ruling, not a gap — uniform service is impossible + /// (a major browser engine omits AES-192 from WebCrypto + /// deliberately and permanently) and the variant earns no + /// exception; see `README.md`, "Design notes". Declared because + /// AES closes the set. + aes192, + aes256, + } +} + +/// AES-GCM key minting (NIST SP 800-38D). +/// +/// Keys minted here drive `aead.aead-key.seal`/`open` with the parameters +/// as the Web Cryptography API's `AesGcmParams` defines them, per call. +/// +/// Security: +/// - The caller owns nonce uniqueness. Nonce reuse under one key defeats +/// the algorithm's confidentiality and authenticity guarantees; use a +/// deterministic per-key uniqueness scheme (SP 800-38D §8), or draw +/// 96-bit nonces at random only within §8.2.2's invocation bounds. +/// - Short tags weaken the forgery bound (SP 800-38D Appendix C) and are +/// opt-in only, behind an explicit `tag-size`. +/// - 96 bits (12 bytes) is the standard nonce size (the `nonce-size` +/// getter) and the fast path. Other lengths cost an extra GHASH pass +/// (SP 800-38D §7.1) and forfeit §8.2's deterministic-construction +/// guidance. +/// +/// Parameter acceptance: +/// +/// - **Nonce**: 12 to 128 bytes inclusive; a length outside that window +/// fails `error.invalid-nonce` on every implementation (see +/// `README.md`, "Portability contract"). The window is the range every +/// implementation serves identically. Nonces shorter than the 96-bit +/// standard size are cryptographically discouraged (SP 800-38D +/// recommends the 96-bit construction), and lengths beyond 128 bytes +/// serve no protocol in use. +/// - **Tag size**: 16 bytes by default (`tag-size` of `none`); the +/// algorithm's set is 4, 8, 12, 13, 14, 15, or 16 bytes (the registry's +/// 32–128-bit set). Implementations MAY decline sizes their security +/// policy does not serve with `error.unsupported`. +/// - **Bounds**: GCM defines a maximum plaintext of 2^39 − 256 bits +/// (~64 GiB) per invocation and an AAD bound of 2^64 − 1 bits. +/// Implementations reaching them fail rather than wrap, but their +/// buffering limits (`error.other`) are reached far earlier in practice. +interface aes-gcm { + use types.{error}; + use aead.{aead-key, aead-key-options}; + use aes.{aes-variant}; + use derivation.{derive-input}; + use wrapping.{unwrap-input}; + + /// Import raw key material as the declared AES variant. + /// + /// `variant` is deliberately redundant with `raw`'s length: material of + /// any other length fails with `error.invalid-key`, so malformed + /// material (for example, key bytes accidentally left hex-encoded) + /// cannot silently mint a key of an unintended size. An implementation + /// not serving the variant (AES-192 is declined everywhere — see the + /// `aes-variant` doc) fails with `error.unsupported`. + import-key-raw: async func(%variant: aes-variant, raw: list, options: aead-key-options) -> result; + + /// Import an RFC 7517 JSON Web Key as an AES-GCM key of the declared + /// variant. + /// + /// `jwk` is the JWK as JSON text; the implementation owns the parse + /// (see `README.md`, "JWK contract"). The key must be an `oct` key + /// whose `alg`, if present, names the declared variant + /// (`"A128GCM"`/`"A192GCM"`/`"A256GCM"`); the decoded material is then + /// subject to `import-key-raw`'s contract. + import-key-jwk: async func(%variant: aes-variant, jwk: string, options: aead-key-options) -> result; + + /// Generate a fresh random key of the given AES variant. Fails with + /// `error.unsupported` if this implementation does not serve the + /// variant. + generate-key: async func(%variant: aes-variant, options: aead-key-options) -> result; + + /// Mint a key from a parameterized derivation: the derivation runs + /// at the variant's key length (WebCrypto's `deriveKey` chain — get + /// key length, derive bits, import) and the result is subject to + /// `import-key-raw`'s contract. + /// + /// Requires `can-derive-key` on the input, else `error.not-permitted`. + /// Requesting an *extractable* key requires `can-derive-bits` too: an + /// exportable key is bits disclosure by other means. + derive-key: async func(%variant: aes-variant, input: borrow, options: aead-key-options) -> result; + + /// Mint a key from unwrapped key material (see the `wrapping` + /// interface): `input`'s bytes are read as raw key material, subject + /// to `import-key-raw`'s contract. `input` is consumed. + /// + /// The minted key's usages and extractability come from `options` + /// alone (the W3C Web Cryptography API's `unwrapKey` model). + unwrap-key-raw: async func(%variant: aes-variant, input: unwrap-input, options: aead-key-options) -> result; + + /// Mint a key from unwrapped key material read as an RFC 7517 JSON + /// Web Key, subject to `import-key-jwk`'s contract plus the + /// unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed; see `unwrap-key-raw` for the + /// options model. + unwrap-key-jwk: async func(%variant: aes-variant, input: unwrap-input, options: aead-key-options) -> result; +} + +/// AES-CBC key minting (NIST SP 800-38A; PKCS#7 padding, the Web +/// Cryptography API's `AES-CBC`): `cipher.cipher-key`s for the +/// unauthenticated mode, served for compatibility with CBC-committed +/// formats. Read the `cipher` interface's Security notes first. +/// +/// Ciphertext is the padded encryption of the plaintext: always a +/// non-zero multiple of 16 bytes (a full padding block is added when the +/// plaintext is already block-aligned). +/// +/// Security: +/// - The 16-byte IV must be *unpredictable* to an attacker who can +/// influence future plaintexts (CWE-329; the BEAST class), not merely +/// unique: generate it fresh and randomly per message. +/// - CBC with visible decryption failures is padding-oracle-prone by +/// nature. The `cipher` kind's uniform-failure rule bounds what this +/// API reveals to the verdict itself; protocols that surface the +/// verdict per message to an active attacker remain at risk. Prefer +/// `aes-gcm` anywhere the format is not already fixed. +interface aes-cbc { + use types.{error}; + use cipher.{cipher-key, cipher-key-options}; + use aes.{aes-variant}; + use derivation.{derive-input}; + use wrapping.{unwrap-input}; + + /// Import raw key material as the declared AES variant (see + /// `aes-gcm.import-key-raw` for the variant-redundancy contract). + import-key-raw: async func(%variant: aes-variant, raw: list, options: cipher-key-options) -> result; + + /// Import an RFC 7517 JSON Web Key as an AES-CBC key of the declared + /// variant. The key must be an `oct` key whose `alg`, if present, + /// names the declared variant (`"A128CBC"`/`"A192CBC"`/`"A256CBC"`); + /// otherwise as `aes-gcm.import-key-jwk`. + import-key-jwk: async func(%variant: aes-variant, jwk: string, options: cipher-key-options) -> result; + + /// Generate a fresh random key of the given AES variant. Fails with + /// `error.unsupported` if this implementation does not serve the + /// variant. + generate-key: async func(%variant: aes-variant, options: cipher-key-options) -> result; + + /// Mint a key from a parameterized derivation (the + /// `aes-gcm.derive-key` contract, minting a `cipher-key`). + derive-key: async func(%variant: aes-variant, input: borrow, options: cipher-key-options) -> result; + + /// Mint a key from unwrapped key material read as raw bytes, subject + /// to `import-key-raw`'s contract. `input` is consumed; see + /// `aes-gcm.unwrap-key-raw` for the options model. + unwrap-key-raw: async func(%variant: aes-variant, input: unwrap-input, options: cipher-key-options) -> result; + + /// Mint a key from unwrapped key material read as an `oct` JWK, + /// subject to `import-key-jwk`'s contract. `input` is consumed. + unwrap-key-jwk: async func(%variant: aes-variant, input: unwrap-input, options: cipher-key-options) -> result; +} + +/// AES-CTR key minting (NIST SP 800-38A counter mode, the Web +/// Cryptography API's `AES-CTR`): `cipher.cipher-key`s for the +/// unauthenticated mode, served for compatibility with CTR-committed +/// formats. Read the `cipher` interface's Security notes first. +/// +/// Ciphertext is exactly the plaintext's length. The per-call `iv` is the +/// 16-byte *initial counter block*, and `counter-length` (required; 1 to +/// 128) is the width in bits of its rightmost, incrementing portion — +/// WebCrypto's `AesCtrParams`. The counter wraps around within that width +/// without carrying into the fixed portion; a message longer than +/// 2^`counter-length` blocks fails rather than reuse counter values. +/// +/// Security: +/// - Every counter block a key ever consumes must be unique: a repeated +/// block is a two-time pad and forfeits confidentiality outright. +/// Uniqueness spans messages — the caller partitions the counter space +/// across messages via the fixed portion, per SP 800-38A Appendix B. +interface aes-ctr { + use types.{error}; + use cipher.{cipher-key, cipher-key-options}; + use aes.{aes-variant}; + use derivation.{derive-input}; + use wrapping.{unwrap-input}; + + /// Import raw key material as the declared AES variant (see + /// `aes-gcm.import-key-raw` for the variant-redundancy contract). + import-key-raw: async func(%variant: aes-variant, raw: list, options: cipher-key-options) -> result; + + /// Import an RFC 7517 JSON Web Key as an AES-CTR key of the declared + /// variant. The key must be an `oct` key whose `alg`, if present, + /// names the declared variant (`"A128CTR"`/`"A192CTR"`/`"A256CTR"`); + /// otherwise as `aes-gcm.import-key-jwk`. + import-key-jwk: async func(%variant: aes-variant, jwk: string, options: cipher-key-options) -> result; + + /// Generate a fresh random key of the given AES variant. Fails with + /// `error.unsupported` if this implementation does not serve the + /// variant. + generate-key: async func(%variant: aes-variant, options: cipher-key-options) -> result; + + /// Mint a key from a parameterized derivation (the + /// `aes-gcm.derive-key` contract, minting a `cipher-key`). + derive-key: async func(%variant: aes-variant, input: borrow, options: cipher-key-options) -> result; + + /// Mint a key from unwrapped key material read as raw bytes, subject + /// to `import-key-raw`'s contract. `input` is consumed; see + /// `aes-gcm.unwrap-key-raw` for the options model. + unwrap-key-raw: async func(%variant: aes-variant, input: unwrap-input, options: cipher-key-options) -> result; + + /// Mint a key from unwrapped key material read as an `oct` JWK, + /// subject to `import-key-jwk`'s contract. `input` is consumed. + unwrap-key-jwk: async func(%variant: aes-variant, input: unwrap-input, options: cipher-key-options) -> result; +} + +/// AES-KW key minting (RFC 3394; NIST SP 800-38F's KW): `key-wrap.kw-key`s +/// for the deterministic key-wrapping mode, the Web Cryptography API's +/// `AES-KW`. Read the `key-wrap` interface's Security notes first. +/// +/// Keys minted here drive `kw-key.wrap`/`unwrap`. The wrapped form is RFC +/// 3394's: the input's length plus 8 bytes, carrying the 64-bit ICV whose +/// failure `unwrap` reports as `error.authentication-failed`. +/// +/// Input domains: +/// - `kw-key.wrap`: the serialized input must be a multiple of 8 bytes, +/// at least 16 (RFC 3394 §2; every AES and multiple-of-8 HMAC +/// raw key qualifies). Anything else fails `error.invalid-key`. +/// - JWK-formatted wrap input (a `to-wrap-input-jwk` serialization) is +/// first padded with ASCII spaces (0x20) to a multiple of 8 — the +/// behavior Web Cryptography API engines interoperate on. The JWK +/// contract's trailing-space tolerance carries the round trip (see +/// `README.md`, "JWK contract"). No other format is padded. +/// - `kw-key.unwrap`: a wrapped input is a multiple of 8 bytes, at +/// least 24 (the wrapped form of the smallest wrap input). Input +/// outside that domain cannot carry the wire format and fails +/// `error.authentication-failed`, indistinguishable from an ICV +/// failure. +/// +/// A FIPS 140-3 approved-mode provider may serve this interface: KW is an +/// SP 800-38F approved mode and draws no IV. +interface aes-kw { + use types.{error}; + use key-wrap.{kw-key, kw-key-options}; + use aes.{aes-variant}; + use derivation.{derive-input}; + use wrapping.{unwrap-input}; + + /// Import raw key material as the declared AES variant (see + /// `aes-gcm.import-key-raw` for the variant-redundancy contract). + import-key-raw: async func(%variant: aes-variant, raw: list, options: kw-key-options) -> result; + + /// Import an RFC 7517 JSON Web Key as an AES-KW key of the declared + /// variant. The key must be an `oct` key whose `alg`, if present, + /// names the declared variant (`"A128KW"`/`"A192KW"`/`"A256KW"`); + /// otherwise as `aes-gcm.import-key-jwk`. + import-key-jwk: async func(%variant: aes-variant, jwk: string, options: kw-key-options) -> result; + + /// Generate a fresh random key of the given AES variant. Fails with + /// `error.unsupported` if this implementation does not serve the + /// variant. + generate-key: async func(%variant: aes-variant, options: kw-key-options) -> result; + + /// Mint a key from a parameterized derivation (the + /// `aes-gcm.derive-key` contract, minting a `kw-key`). + derive-key: async func(%variant: aes-variant, input: borrow, options: kw-key-options) -> result; + + /// Mint a key from unwrapped key material read as raw bytes, subject + /// to `import-key-raw`'s contract. `input` is consumed; see + /// `aes-gcm.unwrap-key-raw` for the options model. + unwrap-key-raw: async func(%variant: aes-variant, input: unwrap-input, options: kw-key-options) -> result; + + /// Mint a key from unwrapped key material read as an `oct` JWK, + /// subject to `import-key-jwk`'s contract. `input` is consumed. + unwrap-key-jwk: async func(%variant: aes-variant, input: unwrap-input, options: kw-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/agreement.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/agreement.wit new file mode 100644 index 00000000..de2d00f9 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/agreement.wit @@ -0,0 +1,134 @@ +package polymorph:webcrypto@0.1.0; + +/// The `key-agreement` primitive kind: two-party Diffie–Hellman-style +/// agreement, producing keying material as a `derive-input` rather than as +/// bytes. +/// +/// This kind has no secret-free half to split off (compare `signature`'s +/// `-verify`/`-sign` interfaces): every operation involves the secret key, +/// and the public key exists to be exchanged. There is deliberately no way +/// to derive a `public-key` from a `secret-key`: the public half is +/// available where it is guaranteed to exist — `generate-key` returns the +/// pair, and an imported secret's JWK carries its public coordinate — while +/// a provider may hold a secret it can use but not read (see `README.md`, +/// "Design notes"). +/// +/// Security: +/// - `agree` combines two key resources, so mixing algorithms (an X25519 +/// secret with an ECDH public) is *representable* — the one place in +/// the package where misuse is checked rather than unrepresentable, +/// because an operation takes two capabilities. The check is the W3C +/// Web Cryptography API's own: an algorithm mismatch — including a +/// curve mismatch between two ECDH keys — fails `error.invalid-key`. +interface key-agreement { + use types.{error}; + use derivation.{derive-input}; + use wrapping.{wrap-input}; + + /// Mint-time policy for agreement secret keys, following the + /// package-wide options contract (see `README.md`). The derive grants + /// are the W3C Web Cryptography API's usage pair for agreement keys, + /// and flow to every `derive-input` the key's `agree` mints. + /// `extractable` gates the secret-key exports (`export-key-jwk`/ + /// `-pkcs8` and the `to-wrap-input-*` functions); platform key + /// storage honors it too (the `signing-key` precedent). + resource agreement-key-options { + constructor(); + /// Whether inputs agreed by the minted key may yield raw bits. + /// Disabled by default. + can-derive-bits: func(allowed: bool); + /// Whether inputs agreed by the minted key may mint keys. Disabled + /// by default. + can-derive-key: func(allowed: bool); + /// Whether the minted key's material may be exported + /// (`secret-key.export-key-jwk`/`-pkcs8`). Disabled by default. + extractable: func(allowed: bool); + } + + /// A public key: exchangeable, secret-free. + resource public-key { + /// The registry name of the algorithm family this key is bound to, + /// e.g. `"X25519"` (the Web Cryptography API's + /// `KeyAlgorithm.name`). + algorithm-name: func() -> string; + + /// The public key material, in the minting interface's documented + /// public format. Fallible with `error.other` even though this + /// resource has no extractability gate: a provider may hold the + /// key as a handle it can use but not read. + export-key-raw: async func() -> result, error>; + + /// The public key as a JWK, e.g. an RFC 8037 OKP public key for + /// X25519. See `mac-key.export-key-jwk` for the package-wide JWK + /// contract; the same handle-not-bytes fallibility as `export-key-raw` + /// applies. + export-key-jwk: async func() -> result; + + /// The public key as an X.509 SubjectPublicKeyInfo (DER). The + /// same handle-not-bytes fallibility as `export-key-raw` applies. + export-key-spki: async func() -> result, error>; + } + + /// A secret key. `agree` is one-shot on the immutable key; the + /// derivation state lives in the `derive-input` it returns. + resource secret-key { + /// The shared secret with `peer`, as a `derive-input` whose grants + /// are copied from this key's mint options (the Web Cryptography + /// API's model: derive usages live on the secret key). + /// + /// The returned input has a *natural* output length — the + /// agreement's full shared secret (32 bytes for X25519; the + /// curve's field size for ECDH) — so `derive-bits(none)` returns + /// the whole secret and `hkdf-sha2.prepare-from` accepts it as + /// IKM. + /// + /// Security: + /// - Fails `error.invalid-key` if the shared secret is the + /// all-zero value (a small-order `peer`), checked in constant + /// time — the W3C Web Cryptography API's mandatory contributory + /// check, at the operation that computes the secret. Whether a + /// degenerate peer can reach this check is the minting + /// interface's import contract: X25519's deliberately + /// permissive import admits one, and it surfaces here; ECDH's + /// strict import rejects it at the mint. + /// - Fails `error.invalid-key` if `peer` is bound to a different + /// algorithm than this key. + agree: async func(peer: borrow) -> result; + + /// See `public-key.algorithm-name`. + algorithm-name: func() -> string; + + /// Whether inputs agreed by this key may yield raw bits. See + /// `agreement-key-options.can-derive-bits`. + can-derive-bits: func() -> bool; + + /// Whether inputs agreed by this key may mint keys. See + /// `agreement-key-options.can-derive-key`. + can-derive-key: func() -> bool; + + /// Whether the export functions may return this key's material. + /// See `agreement-key-options.extractable`. + extractable: func() -> bool; + + /// The secret key as an RFC 8037 OKP private JWK. Fails + /// `error.not-extractable` unless the key was minted extractable; + /// fallible beyond the gate like every export (`README.md`, + /// "Extractability"). + export-key-jwk: async func() -> result; + + /// The secret key as a PKCS#8 PrivateKeyInfo (DER), behind the + /// same extractability gate as `export-key-jwk`. + export-key-pkcs8: async func() -> result, error>; + + /// The private-key JWK serialization as a `wrap-input`, for + /// wrapping under another key (see the `wrapping` interface). + /// Behind the same extractability gate as `export-key-jwk`, and + /// fallible beyond it like every export; the material itself + /// never reaches the caller. + to-wrap-input-jwk: async func() -> result; + + /// The PKCS#8 serialization as a `wrap-input`, behind the same + /// gate. + to-wrap-input-pkcs8: async func() -> result; + } +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/derivation.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/derivation.wit new file mode 100644 index 00000000..2150b456 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/derivation.wit @@ -0,0 +1,87 @@ +package polymorph:webcrypto@0.1.0; + +/// The `derivation` primitive kind: turning secrets that are not yet keys — +/// an agreement's shared secret, imported input keying material (IKM), a +/// password — into bits or typed keys, without the secret transiting the +/// caller. +/// +/// Every WebCrypto `deriveBits`/`deriveKey` call operates on a pair the +/// spec never names: the base key plus the normalized algorithm parameters +/// (IKM + salt/info; password + salt/iterations; private key + peer +/// public). `derive-input` is that pair as a resource — a fully +/// parameterized derivation, lacking only the output length, which the +/// platform too supplies per use. +/// +/// Sources construct it on their own interfaces (`hkdf-sha2.prepare`, +/// `pbkdf2-sha2.prepare`, an agreement's `agree`); targets consume it +/// beside `import-key-raw` on their minting interfaces (`aes-gcm.derive-key`, +/// `hmac-sha2.derive-key`). +/// +/// Security: +/// - Raw secret bytes enter only on the KDF interfaces, so nothing +/// reaching a `derive-key` has skipped agreement-or-KDF, and no +/// operation anywhere returns a base secret's bytes. WebCrypto's forced +/// non-extractability on KDF base keys is a structural property here +/// rather than a checked one. +interface derivation { + use types.{error}; + + /// Mint-time policy for derivation base secrets, following the + /// package-wide options contract (see `README.md`): the constructor + /// grants nothing, at least one grant is required to mint, and the + /// vocabulary is WebCrypto's usage pair for derive-capable keys. + resource derive-options { + constructor(); + /// Whether inputs built on this base secret may yield raw bits + /// through `derive-input.derive-bits`, and — because an extractable + /// key is bits disclosure by other means — whether they may mint + /// *extractable* keys. Disabled by default. + can-derive-bits: func(allowed: bool); + /// Whether inputs built on this base secret may mint keys through + /// the target interfaces' `derive-key`. Disabled by default. + can-derive-key: func(allowed: bool); + } + + /// A fully parameterized derivation. The output may already be + /// computed, or the computation may still be pending. + /// + /// Grants are copied from the base secret (or upstream input) at + /// construction; parameterization neither grants nor revokes. + /// + /// Security: + /// - An implementation may run the derivation as soon as the input is + /// constructed, or wait until the input is used. While the + /// computation is pending, the input holds its own copy of the base + /// secret, so a secret stays in memory as long as any resource + /// derived from it. Implementations SHOULD NOT keep a base secret in + /// memory longer than the resources that denote it, and SHOULD + /// compute early where that shortens the window (for HKDF, the + /// extract step at `prepare` retains only the PRK and drops the IKM + /// copy). + resource derive-input { + /// Whether `derive-bits` (and minting extractable keys) is + /// permitted. See `derive-options.can-derive-bits`. + can-derive-bits: func() -> bool; + + /// Whether the target interfaces' `derive-key` is permitted. See + /// `derive-options.can-derive-key`. + can-derive-key: func() -> bool; + + /// The derived bits. + /// + /// `length` is in bits, WebCrypto's denomination for this + /// parameter, and must be a multiple of 8 — none of this package's + /// implementations serve sub-byte outputs (the platform zero-pads + /// them; a consumer wanting truncation truncates). `none` means + /// the source's natural output length; sources without one — every + /// KDF, whose output length is a caller choice — fail + /// `error.other`, which is the platform's own null-length + /// behavior for them (HKDF and PBKDF2 throw `OperationError` + /// where the agreements return the whole secret). + /// + /// Fails `error.not-permitted` without the `can-derive-bits` + /// grant. Fallible even when granted: a platform-resident source + /// may be usable but unreadable. + derive-bits: async func(length: option) -> result, error>; + } +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/ecdh.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/ecdh.wit new file mode 100644 index 00000000..9d361f39 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/ecdh.wit @@ -0,0 +1,123 @@ +package polymorph:webcrypto@0.1.0; + +/// ECDH key agreement over the NIST prime-order curves (SP 800-56A), as +/// the Web Cryptography API serves it. +/// +/// Keys returned here drive `key-agreement.secret-key.agree`, exactly as +/// X25519's do: the `derive-input` it returns feeds `derive-bits`, the +/// key-minting interfaces' `derive-key`, and the per-hash KDF prepare +/// interfaces. The shared secret is the x-coordinate of the agreed point +/// (SP 800-56A's `Z`), so the natural output length is the curve's field +/// size: 32 bytes for P-256, 48 bytes for P-384. Keys report +/// `algorithm-name` `"ECDH"` regardless of curve, and `agree` fails +/// `error.invalid-key` on a curve-mismatched peer — the same derive-time +/// check the `key-agreement` kind documents for algorithm-mismatched +/// peers. +/// +/// Formats follow the format-admission rule (see `README.md`, "Design +/// notes"): every format is one a platform-backed host passes to the +/// platform verbatim. The public key travels as an uncompressed SEC1 +/// point (WebCrypto's public-only `raw` format for EC keys); the secret +/// key travels as PKCS#8 or an EC private JWK. Bare secret scalars have +/// no platform door and are not a format here. +/// +/// Security: +/// - Public imports are strict, unlike X25519's deliberately permissive +/// raw import: a point not on the declared variant's curve fails +/// `error.invalid-key` at import, as it does on the platform. A valid +/// point multiplied by a valid scalar on these prime-order curves +/// cannot produce the point at infinity, so a degenerate peer is +/// rejected where it enters rather than surfacing at `agree` (the +/// kind's contributory check stays satisfied by construction). +interface ecdh { + use types.{error}; + use key-agreement.{public-key, secret-key, agreement-key-options}; + use wrapping.{unwrap-input}; + + /// The served curves. (Each algorithm interface names its closed set + /// of parameterizations `-variant`; ECDH's parameter is + /// the curve alone.) + enum ecdh-variant { + /// NIST P-256 (secp256r1). + p256, + /// NIST P-384 (secp384r1). + p384, + /// NIST P-521 (secp521r1). Declared because the WebCrypto + /// registry serves it and enum growth is breaking; no current + /// implementation of this package serves it — expect + /// `error.unsupported` unless composed with a provider that + /// specifically offers it. + p521, + } + + /// Import a public key as an uncompressed SEC1 point (`04 ‖ x ‖ y`; + /// 65 bytes for P-256, 97 bytes for P-384 — WebCrypto's `raw` + /// format). Anything else — including compressed points and points + /// not on the declared variant's curve — fails with + /// `error.invalid-key`. `public-key.export-key-raw` returns this + /// same form. + import-public-key-raw: async func(%variant: ecdh-variant, raw: list) -> result; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER). The + /// curve must be named by OID and must match the declared variant's, + /// or the import fails with `error.invalid-key`: an encoding that + /// carries explicit ECParameters instead of the named-curve OID is + /// rejected even when the parameters describe the variant's curve. A + /// point not on the curve is always rejected. An uncompressed point + /// is always accepted; whether a *compressed* encoding is accepted is + /// implementation-defined, as it is across WebCrypto engines — do not + /// rely on either behavior. + import-public-key-spki: async func(%variant: ecdh-variant, spki: list) -> result; + + /// Import a public key as an EC public JWK (`kty: "EC"`, with `crv`, + /// `x`, and `y`). `jwk` is the JWK as JSON text; see + /// `mac-key.export-key-jwk` for the package-wide JWK contract + /// (`alg` is ignored entirely, WebCrypto's rule for the ECDH + /// family). The JWK's `crv` must match the declared variant's curve + /// (`error.invalid-key` otherwise), and the encoded point is + /// admitted exactly as `import-public-key-raw` admits it. + import-public-key-jwk: async func(%variant: ecdh-variant, jwk: string) -> result; + + /// Import a static secret key as an EC private JWK (`kty: "EC"`, + /// with `crv`, `d`, and the public coordinates `x`/`y`, which RFC + /// 7518 makes mandatory — this is inherently the public+private + /// form). `jwk` is the JWK as JSON text; see `mac-key.export-key-jwk` + /// for the package-wide JWK contract, including `ext` validation + /// against the options' extractability. `d` is the curve's scalar + /// and must lie in `[1, n-1]` (`error.invalid-key` otherwise). + /// + /// Security: + /// - Implementations MAY reject a JWK whose `x`/`y` is not the + /// public point of `d` with `error.invalid-key`, and MUST NOT + /// trust `x`/`y` for any operation: the imported key's identity is + /// `d`'s. (The W3C Web Cryptography API's import steps do not + /// mandate the consistency check, and engines differ, so a + /// platform-backed host cannot promise it.) + import-secret-key-jwk: async func(%variant: ecdh-variant, jwk: string, options: agreement-key-options) -> result; + + /// Import a static secret key as a PKCS#8 PrivateKeyInfo (DER, the + /// RFC 5915 ECPrivateKey body). The encoded curve must match the + /// declared variant's (`error.invalid-key`), and the scalar must lie + /// in `[1, n-1]`; an embedded public key, when present, is validated + /// against the scalar and never trusted on its own. + import-secret-key-pkcs8: async func(%variant: ecdh-variant, pkcs8: list, options: agreement-key-options) -> result; + + /// Generate a fresh key pair on the declared variant's curve. + generate-key: async func(%variant: ecdh-variant, options: agreement-key-options) -> result, error>; + + /// Mint a static secret key from unwrapped key material (see the + /// `wrapping` interface): `input`'s bytes are read as an EC private + /// JWK, subject to `import-secret-key-jwk`'s contract plus the + /// unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed. + /// + /// The minted key's grants and extractability come from `options` + /// alone (the W3C Web Cryptography API's `unwrapKey` model). + unwrap-secret-key-jwk: async func(%variant: ecdh-variant, input: unwrap-input, options: agreement-key-options) -> result; + + /// Mint a static secret key from unwrapped key material read as a + /// PKCS#8 PrivateKeyInfo, subject to `import-secret-key-pkcs8`'s + /// contract. `input` is consumed; see `unwrap-secret-key-jwk` for + /// the options model. + unwrap-secret-key-pkcs8: async func(%variant: ecdh-variant, input: unwrap-input, options: agreement-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/ecdsa.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/ecdsa.wit new file mode 100644 index 00000000..44425e7b --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/ecdsa.wit @@ -0,0 +1,138 @@ +package polymorph:webcrypto@0.1.0; + +/// ECDSA verification-key minting (FIPS 186-5). +/// +/// The variant binds curve and digest together at mint — unlike WebCrypto, +/// where the hash is a per-operation parameter — so a granted key cannot be +/// used with a weaker digest than its minter chose (see `README.md`, +/// "Design notes"). Keys report `algorithm-name` `"ECDSA"` plus the +/// variant's curve and hash. +/// +/// Signatures are fixed-width `r ‖ s` (IEEE P1363, WebCrypto's format): +/// 64 bytes for P-256, 96 bytes for P-384. ASN.1 DER signatures are not +/// accepted. Verification requires `r` and `s` in `[1, n-1]`; the +/// fixed-width format leaves no further encoding freedom. +/// +/// Security: +/// - ECDSA is inherently malleable — `(r, s)` and `(r, n-s)` both verify — +/// and this interface deliberately imposes no low-`s` normalization. +/// Do not build systems that assume signature uniqueness. +interface ecdsa-verify { + use types.{error}; + use signature.{verifying-key}; + + /// The served curve/digest pairings. Every variant still binds its + /// hash at mint — a key can never be used with a digest its minter did + /// not choose — but the cross pairings of the served curves and SHA-2 + /// digests are representable, as WebCrypto's per-operation hash makes + /// them on the platform. + enum ecdsa-variant { + /// NIST P-256 (secp256r1) with SHA-256. + p256-sha256, + /// NIST P-256 with SHA-384. + p256-sha384, + /// NIST P-256 with SHA-512. + p256-sha512, + /// NIST P-384 with SHA-256. + p384-sha256, + /// NIST P-384 (secp384r1) with SHA-384. + p384-sha384, + /// NIST P-384 with SHA-512. + p384-sha512, + /// NIST P-521 (secp521r1) with SHA-512. Declared because the + /// WebCrypto registry serves it and enum growth is breaking; no + /// current implementation of this package serves it — expect + /// `error.unsupported` unless composed with a provider that + /// specifically offers it. + p521-sha512, + } + + /// Import a public key as an uncompressed SEC1 point (`04 ‖ x ‖ y`; + /// 65 bytes for P-256, 97 bytes for P-384 — WebCrypto's `raw` format). + /// Anything else — including compressed points and points not on the + /// curve — fails with `error.invalid-key`. `verifying-key.export-key-raw` + /// returns this same form. + import-verifying-key-raw: async func(%variant: ecdsa-variant, raw: list) -> result; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER). The + /// curve must be named by OID and must match the declared variant's, + /// or the import fails with `error.invalid-key`: an encoding that + /// carries explicit ECParameters instead of the named-curve OID is + /// rejected even when the parameters describe the variant's curve. A + /// point not on the curve is always rejected. An uncompressed point + /// is always accepted; whether a *compressed* encoding is accepted is + /// implementation-defined, as it is across WebCrypto engines — do not + /// rely on either behavior. + import-verifying-key-spki: async func(%variant: ecdsa-variant, spki: list) -> result; + + /// Import a public key as an EC JWK (`kty: "EC"`, with `crv`, `x`, + /// and `y`). `jwk` is the JWK as JSON text; see + /// `mac-key.export-key-jwk` for the package-wide JWK contract. The + /// JWK's `crv` must match the declared variant's curve + /// (`error.invalid-key` otherwise), and an `alg` member, when + /// present, must be the curve's JOSE signature alg (`"ES256"` for + /// P-256, `"ES384"` for P-384) — curve-determined, so it does not + /// vary with the variant's mint-bound hash. + import-verifying-key-jwk: async func(%variant: ecdsa-variant, jwk: string) -> result; +} + +/// ECDSA signing-key minting (FIPS 186-5). +/// +/// Security: +/// - ECDSA signing handles a per-signature secret nonce whose timing +/// leakage is key-recovering. Providers in attacker-observable timing +/// domains do not export this interface, and compositions requiring it +/// then fail at composition time (see `README.md`, "Timing-channel +/// policy"). Host-backed providers serve it. +/// +/// Signing keys import as PKCS#8 or an EC private JWK — the platform +/// pass-through formats — and never as bare scalars (no platform door; +/// see `README.md`, "Design notes", the format-admission rule). Imports +/// return only the signing key: the public half is never derived from a +/// private import (the w3c/webcrypto#356 gap), so importers needing it +/// supply it separately via `ecdsa-verify`. +/// +/// Note the signature is not deterministic across implementations: RFC +/// 6979 (deterministic) and randomized-k implementations both verify, but +/// produce different bytes for the same input. +interface ecdsa-sign { + use types.{error}; + use signature.{signing-key, signing-key-options, verifying-key}; + use ecdsa-verify.{ecdsa-variant}; + use wrapping.{unwrap-input}; + + /// Generate a fresh random signing key of the declared variant, + /// returning both halves — the only point at which every provider is + /// guaranteed to have the public key on hand. + generate-key: async func(%variant: ecdsa-variant, options: signing-key-options) -> result, error>; + + /// Import a signing key as a PKCS#8 PrivateKeyInfo (DER, the SEC1 + /// private-key body). The encoded curve must match the declared + /// variant's (`error.invalid-key`); an embedded public key, when + /// present, is validated against the scalar and never trusted on its + /// own. Returns only the signing key (see the interface doc). + import-signing-key-pkcs8: async func(%variant: ecdsa-variant, pkcs8: list, options: signing-key-options) -> result; + + /// Import a signing key as an EC private JWK (`kty: "EC"`, with + /// `crv`, `d`, and the mandatory public coordinates `x`/`y`, which + /// implementations MAY validate against `d` and MUST NOT trust). + /// `crv` and `alg` are validated as in `import-verifying-key-jwk`. + /// See `mac-key.export-key-jwk` for the package-wide JWK contract. + import-signing-key-jwk: async func(%variant: ecdsa-variant, jwk: string, options: signing-key-options) -> result; + + /// Mint a signing key from unwrapped key material (see the `wrapping` + /// interface): `input`'s bytes are read as a PKCS#8 PrivateKeyInfo, + /// subject to `import-signing-key-pkcs8`'s contract. `input` is + /// consumed. + /// + /// The minted key's usages and extractability come from `options` + /// alone (the W3C Web Cryptography API's `unwrapKey` model). + unwrap-signing-key-pkcs8: async func(%variant: ecdsa-variant, input: unwrap-input, options: signing-key-options) -> result; + + /// Mint a signing key from unwrapped key material read as an EC + /// private JWK, subject to `import-signing-key-jwk`'s contract plus + /// the unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed; see `unwrap-signing-key-pkcs8` + /// for the options model. + unwrap-signing-key-jwk: async func(%variant: ecdsa-variant, input: unwrap-input, options: signing-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/ed25519.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/ed25519.wit new file mode 100644 index 00000000..b913527a --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/ed25519.wit @@ -0,0 +1,101 @@ +package polymorph:webcrypto@0.1.0; + +/// Ed25519 verification-key minting (RFC 8032). +/// +/// Ed25519 has no parameters: 32-byte public keys, 64-byte signatures. +/// Keys report `algorithm-name` `"Ed25519"` and `none` for curve and hash. +/// +/// Security — the verification criterion. RFC 8032 leaves edge cases open +/// and real implementations disagree on them; divergent verify results +/// across providers is a vulnerability class, so this interface pins one +/// policy: +/// - Keys minted here verify with the *cofactorless* equation +/// (`[S]B = R + [k]A`). +/// - Implementations MUST reject signatures with a non-canonical scalar +/// (`S ≥ L`), a non-canonically-encoded or small-order `R`, and keys +/// with a non-canonically-encoded or small-order `A` — the strict +/// semantics of ed25519-dalek's `verify_strict`. +/// - Whether a degenerate `A` is rejected at import +/// (`error.invalid-key`) or at verification +/// (`error.authentication-failed`) is implementation-defined; the +/// guarantee is that no signature ever verifies under one. +/// - Inputs with *mixed-order* (torsion-component, not small-order) `A` +/// or `R` are the remaining freedom: they verify iff the cofactorless +/// equation holds. +/// +/// None of the rejected inputs can be produced by an honest RFC 8032 +/// signer, so this strictness costs no interoperability. +interface ed25519-verify { + use types.{error}; + use signature.{verifying-key}; + + /// Import a 32-byte raw public key (RFC 8032 encoding). Material of + /// any other length fails with `error.invalid-key`; a non-canonical or + /// small-order encoding is rejected here or at verification, per the + /// interface's verification criterion. + import-verifying-key-raw: async func(raw: list) -> result; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER, RFC + /// 8410 algorithm id 1.3.101.112). The embedded point is subject to + /// the same strict criterion as `import-verifying-key-raw`. + import-verifying-key-spki: async func(spki: list) -> result; + + /// Import a public key as an RFC 8037 OKP public JWK (`kty: "OKP"`, + /// `crv: "Ed25519"`, `x`). `jwk` is the JWK as JSON text; see + /// `mac-key.export-key-jwk` for the package-wide JWK contract. An + /// `alg` member, when present, must be `"Ed25519"` or `"EdDSA"`. The + /// same strict point criterion applies. + import-verifying-key-jwk: async func(jwk: string) -> result; +} + +/// Ed25519 signing-key minting (RFC 8032). +/// +/// Split from `ed25519-verify` so a provider can serve verification alone. +/// Ed25519 signing is constant-time by construction (no per-signature +/// secret nonce, complete addition laws), so providers in shared timing +/// domains can serve it (see `README.md`, "Timing-channel policy"). +/// +/// Signing keys import as PKCS#8 or an OKP private JWK — the platform +/// pass-through formats — and never as a bare seed (see `README.md`, +/// "Design notes", the format-admission rule). Imports return only the +/// signing key; the public half is supplied separately via +/// `ed25519-verify` (there is no derive from a private import). +interface ed25519-sign { + use types.{error}; + use signature.{signing-key, signing-key-options, verifying-key}; + use wrapping.{unwrap-input}; + + /// Generate a fresh random signing key, returning both halves — the + /// only point at which every provider is guaranteed to have the public + /// key on hand. + generate-key: async func(options: signing-key-options) -> result, error>; + + /// Import a signing key as a PKCS#8 PrivateKeyInfo (DER, RFC 8410: + /// the 32-byte seed in a CurvePrivateKey). Wrong OIDs and malformed + /// DER fail with `error.invalid-key`. + import-signing-key-pkcs8: async func(pkcs8: list, options: signing-key-options) -> result; + + /// Import a signing key as an RFC 8037 OKP private JWK + /// (`kty: "OKP"`, `crv: "Ed25519"`, with `x` and `d` both required; + /// an `alg` member, when present, must be `"Ed25519"` or `"EdDSA"`). + /// Implementations MAY reject a JWK whose `x` is not the public key + /// of `d`, and MUST NOT trust `x` for any operation. See + /// `mac-key.export-key-jwk` for the package-wide JWK contract. + import-signing-key-jwk: async func(jwk: string, options: signing-key-options) -> result; + + /// Mint a signing key from unwrapped key material (see the `wrapping` + /// interface): `input`'s bytes are read as a PKCS#8 PrivateKeyInfo, + /// subject to `import-signing-key-pkcs8`'s contract. `input` is + /// consumed. + /// + /// The minted key's usages and extractability come from `options` + /// alone (the W3C Web Cryptography API's `unwrapKey` model). + unwrap-signing-key-pkcs8: async func(input: unwrap-input, options: signing-key-options) -> result; + + /// Mint a signing key from unwrapped key material read as an OKP + /// private JWK, subject to `import-signing-key-jwk`'s contract plus + /// the unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed; see `unwrap-signing-key-pkcs8` + /// for the options model. + unwrap-signing-key-jwk: async func(input: unwrap-input, options: signing-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/encryption.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/encryption.wit new file mode 100644 index 00000000..f5a3bbbf --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/encryption.wit @@ -0,0 +1,154 @@ +package polymorph:webcrypto@0.1.0; + +/// The `public-encryption` primitive kind: asymmetric encryption. Anyone +/// holding the public key encrypts; only the private-key holder decrypts. +/// The dominant use is key transport — wrapping a symmetric key to a +/// recipient — so the wrap operations sit beside encrypt/decrypt, riding +/// the same provider-held intermediates as the `key-wrap` kind (see the +/// `wrapping` interface). +/// +/// Operations take and return whole byte lists rather than streams — the +/// one kind that does. The plaintext is bounded by the key (for RSA-OAEP, +/// the modulus length minus the padding overhead), so there is no +/// unbounded data to stream; a payload above the bound fails with the +/// named extension condition on `encrypt`/`wrap`. +/// +/// Security: +/// - Encryption is randomized: encrypting one plaintext twice yields +/// different ciphertexts, and both decrypt. +/// - `decrypt` and `unwrap` fail with one detail-free error +/// (`error.authentication-failed`): a wrong-length ciphertext, damaged +/// padding, and a mismatched label are indistinguishable, as RFC 8017 +/// requires — a distinguishable verdict is a padding-oracle amplifier. +interface public-encryption { + use types.{error}; + use wrapping.{wrap-input, unwrap-input}; + + /// Mint-time policy for `decryption-key`s, following the package-wide + /// options contract (see `README.md`). The two grants separate + /// disclosure from minting: `decrypt` returns plaintext to the + /// caller, while `unwrap` mints keys whose material the caller never + /// sees — a key granted only `can-unwrap` cannot leak what it + /// transports (the `derive-bits`/`derive-key` split's reasoning). + resource decryption-key-options { + constructor(); + /// Whether the minted key may `decrypt`. Disabled by default. + can-decrypt: func(allowed: bool); + /// Whether the minted key may `unwrap`. Disabled by default. + can-unwrap: func(allowed: bool); + /// Whether the minted key's material may be exported + /// (`decryption-key.export-key-jwk`/`-pkcs8` and the + /// `to-wrap-input-*` functions). Disabled by default. + extractable: func(allowed: bool); + } + + /// A public key: encryption and wrapping, secret-free to hold. + resource encryption-key { + /// Encrypt a plaintext bounded by the key. `label` is optional + /// context bound into the padding: decryption succeeds only + /// under the same label (WebCrypto's `RsaOaepParams.label`). + /// A plaintext above the key's bound fails `error.extension` + /// — origin `"polymorph:webcrypto"`, name `"message-too-long"` — + /// the signal to switch to hybrid wrapping (encrypt a symmetric + /// key, wrap the payload under it). + encrypt: async func(label: option>, plaintext: list) -> result, error>; + + /// Wrap key material serialized as a `wrap-input` (see the + /// `wrapping` interface): the material transits neither caller. + /// The serialized form must fit the key's bound — symmetric-key + /// JWKs do, private-key serializations generally do not — else + /// `error.extension` `"message-too-long"`, as on `encrypt`. + wrap: async func(label: option>, input: wrap-input) -> result, error>; + + /// The registry name of the algorithm family this key is bound + /// to, e.g. `"RSA-OAEP"` (WebCrypto's `KeyAlgorithm.name`). + algorithm-name: func() -> string; + + /// The digest bound at mint, e.g. `"SHA-256"`. + algorithm-hash: func() -> option; + + /// The key's length in bits for algorithms parameterized by one + /// (the RSA modulus length). + algorithm-length: func() -> option; + + /// The public exponent for RSA-family keys, as WebCrypto's + /// `RsaKeyAlgorithm.publicExponent` denominates it: the + /// exponent's big-endian bytes (`[1, 0, 1]` for 65537). `none` + /// for algorithms without one. + algorithm-public-exponent: func() -> option>; + + /// The public key material, in the minting interface's documented + /// public format; algorithms without a raw public form (the RSA + /// family) fail `error.unsupported`. No extractability gate — + /// public keys are unconditionally exportable — but still + /// fallible: a provider may hold a handle it can use but not + /// read (see `README.md`, "Extractability"). + export-key-raw: async func() -> result, error>; + + /// The public key as an X.509 SubjectPublicKeyInfo (DER), with + /// the same fallibility as `export-key-raw`. + export-key-spki: async func() -> result, error>; + + /// The public key as a JWK. See `mac-key.export-key-jwk` for the + /// package-wide JWK contract; the same fallibility applies. + export-key-jwk: async func() -> result; + } + + /// A private key. `decrypt` and `unwrap` are one-shot on the + /// immutable key; the extractability contract in `README.md` applies. + resource decryption-key { + /// Decrypt a ciphertext produced by the matching public key + /// under the same `label`. Fails `error.not-permitted` without + /// the `can-decrypt` grant; every decryption failure is the one + /// detail-free `error.authentication-failed` (see the interface + /// doc). + decrypt: async func(label: option>, ciphertext: list) -> result, error>; + + /// Decrypt a wrapped key into an `unwrap-input` for a typed + /// unwrap mint (see the `wrapping` interface): the material + /// never reaches the caller. Fails `error.not-permitted` without + /// the `can-unwrap` grant; failures are otherwise as `decrypt`. + unwrap: async func(label: option>, ciphertext: list) -> result; + + /// See `encryption-key.algorithm-name`. + algorithm-name: func() -> string; + + /// See `encryption-key.algorithm-hash`. + algorithm-hash: func() -> option; + + /// See `encryption-key.algorithm-length`. + algorithm-length: func() -> option; + + /// See `encryption-key.algorithm-public-exponent`. + algorithm-public-exponent: func() -> option>; + + /// Whether this key may `decrypt`. See + /// `decryption-key-options.can-decrypt`. + can-decrypt: func() -> bool; + + /// Whether this key may `unwrap`. See + /// `decryption-key-options.can-unwrap`. + can-unwrap: func() -> bool; + + /// Whether the export functions may return this key's material. + /// See `decryption-key-options.extractable`. + extractable: func() -> bool; + + /// The private key as a JWK, behind the extractability gate and + /// fallible beyond it like every export (`README.md`, + /// "Extractability"). + export-key-jwk: async func() -> result; + + /// The private key as a PKCS#8 PrivateKeyInfo (DER), behind the + /// same gate. + export-key-pkcs8: async func() -> result, error>; + + /// The private-key JWK serialization as a `wrap-input`, for + /// wrapping under another key. Behind the extractability gate. + to-wrap-input-jwk: async func() -> result; + + /// The PKCS#8 serialization as a `wrap-input`, behind the same + /// gate. + to-wrap-input-pkcs8: async func() -> result; + } +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/hkdf.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/hkdf.wit new file mode 100644 index 00000000..473ee997 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/hkdf.wit @@ -0,0 +1,106 @@ +package polymorph:webcrypto@0.1.0; + +/// HKDF (RFC 5869) input keying material: the hash-independent half of +/// the HKDF surface. The `ikm` resource minted here parameterizes +/// derivations through the per-hash prepare interfaces (`hkdf-sha2`, +/// `hkdf-sha1`), so one imported secret serves either hash family. +/// +/// Security: +/// - No operation returns an `ikm`'s bytes, under any grant: WebCrypto's +/// forced non-extractability on HKDF base keys, made structural. +/// - The implementation holds imported IKM in memory for the resource's +/// construction-to-drop lifetime; that window is the secret's exposure +/// to memory side channels. Import late, drop early. Dropping releases +/// (and, in scrubbing implementations, zeroizes) it. +interface hkdf { + use types.{error}; + use derivation.{derive-options}; + use wrapping.{unwrap-input}; + + /// Input keying material: consumable only by `prepare`, never + /// readable. Usage grants are fixed at import and copied to every + /// input prepared from it. + resource ikm { + /// Whether inputs prepared from this material may yield raw bits. + can-derive-bits: func() -> bool; + /// Whether inputs prepared from this material may mint keys. + can-derive-key: func() -> bool; + } + + /// Import input keying material. Empty material is accepted (RFC + /// 5869 permits it, and the Web Cryptography API serves it), but an + /// implementation enforcing a security policy MAY reject degenerate + /// material with `error.invalid-key`, as on + /// `hmac-sha2.import-key-raw`. Fails `error.not-permitted` if + /// `options` grants nothing, per the package-wide options contract. + import-ikm: async func(raw: list, options: derive-options) -> result; + + /// Mint input keying material from unwrapped bytes (see the + /// `wrapping` interface), subject to `import-ikm`'s contract: a KDF + /// secret can arrive under a wrapping key and parameterize + /// derivations without its bytes ever being observable. Like + /// `import-ikm`, this is not a format choice — the bytes are the + /// material. `input` is consumed. + /// + /// The grants come from `options` alone (the W3C Web Cryptography + /// API's `unwrapKey` model; its forced non-extractability on HKDF + /// base keys is structural here, as on `import-ikm`). + unwrap-ikm: async func(input: unwrap-input, options: derive-options) -> result; +} + +/// HKDF (RFC 5869) over the SHA-2 hash family: the extract-then-expand +/// key derivation function, as the Web Cryptography API serves it. +/// +/// This interface mints no keys. It mints `derive-input`s — parameterized +/// derivations — which the key-minting interfaces' `derive-key` functions +/// consume (`aes-gcm.derive-key`, `hmac-sha2.derive-key`), and which +/// yield raw output through `derivation.derive-input.derive-bits`. The +/// `ikm` resource and its import stay `hkdf`'s, so one imported secret +/// can parameterize derivations of either hash family. +interface hkdf-sha2 { + use types.{error}; + use sha2.{sha2-variant}; + use hkdf.{ikm}; + use derivation.{derive-input}; + + /// Parameterize a derivation: HKDF-Extract with `salt` runs over the + /// material (eagerly, so the returned input retains the PRK rather + /// than the IKM), and `info` is bound for the expand step at use. + /// An implementation not serving the variant fails + /// `error.unsupported` (the truncated SHA-2 variants are unserved + /// package-wide, as for `hmac-sha2`). + prepare: async func(%variant: sha2-variant, input: borrow, salt: list, info: list) -> result; + + /// Parameterize a derivation whose IKM is another derivation's output + /// — the chaining step (a future agreement's shared secret fed to + /// HKDF without transiting the caller). + /// + /// The upstream derivation runs at its natural output length, which + /// requires it to have one: chaining from another KDF's input fails + /// `error.other`, exactly as the platform's `deriveKey(… → "HKDF")` + /// does (`get key length` for a KDF target is null, and a KDF's + /// derive-bits rejects null). Requires `can-derive-key` on the + /// upstream input, whose grants the new input copies. + prepare-from: async func(%variant: sha2-variant, input: borrow, salt: list, info: list) -> result; +} + +/// HKDF (RFC 5869) over SHA-1, for interoperability with SHA-1-committed +/// derivations. The construction is HMAC-based, so SHA-1's collision +/// breaks do not reach it (see `hmac-sha1`'s Security notes); prefer the +/// SHA-2 parameterizations in new designs. +/// +/// This interface adds only the SHA-1 `prepare` steps, parallel to +/// `hkdf-sha2`'s: the `ikm` resource and its import stay `hkdf`'s, so one +/// imported secret can parameterize derivations of either hash family. +interface hkdf-sha1 { + use types.{error}; + use hkdf.{ikm}; + use derivation.{derive-input}; + + /// Parameterize an HKDF-SHA-1 derivation. See `hkdf-sha2.prepare`. + prepare: async func(input: borrow, salt: list, info: list) -> result; + + /// Parameterize an HKDF-SHA-1 derivation from another derivation's + /// output. See `hkdf-sha2.prepare-from`. + prepare-from: async func(input: borrow, salt: list, info: list) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/hmac.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/hmac.wit new file mode 100644 index 00000000..f3ee0bf0 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/hmac.wit @@ -0,0 +1,131 @@ +package polymorph:webcrypto@0.1.0; + +/// HMAC key minting (RFC 2104) over the SHA-2 hash family (FIPS 180-4). +/// +/// Keys minted here drive `mac.mac-key.sign` and `verify`. Adding an +/// algorithm touches only an interface like this one: the generic `mac` +/// surface is closed. +interface hmac-sha2 { + use types.{error}; + use mac.{mac-key, mac-key-options}; + use sha2.{sha2-variant}; + use derivation.{derive-input}; + use wrapping.{unwrap-input}; + + /// Import raw key material as an HMAC key over the declared SHA-2 + /// variant. + /// + /// Empty keys fail `error.invalid-key`. Implementations generally + /// accept any non-empty length (per RFC 2104; keys longer than the + /// block size are hashed first), but an implementation enforcing a + /// security policy (for example FIPS 140-3 approved mode, which + /// requires keys of at least 112 bits) MAY reject shorter keys with + /// `error.invalid-key`. An implementation not serving the variant + /// fails with `error.unsupported`. + import-key-raw: async func(%variant: sha2-variant, raw: list, options: mac-key-options) -> result; + + /// Import an RFC 7517 JSON Web Key as an HMAC key over the declared + /// SHA-2 variant. + /// + /// `jwk` is the JWK as JSON text; the implementation owns the parse + /// (see `README.md`, "JWK contract"). The key must be an `oct` key + /// whose `alg`, if present, names the declared variant + /// (`"HS256"`/`"HS384"`/`"HS512"`), with `ext` validated against the + /// options' extractability; the decoded material is then subject to + /// `import-key-raw`'s contract. + import-key-jwk: async func(%variant: sha2-variant, jwk: string, options: mac-key-options) -> result; + + /// Generate a fresh random HMAC key over the declared SHA-2 variant. + /// + /// `length` is the key length in bits (WebCrypto's + /// `HmacKeyGenParams.length`, reported back by + /// `mac-key.algorithm-length`). `none` means the underlying hash's + /// block size (WebCrypto's `generateKey` default: 512 bits for + /// SHA-256, 1024 for SHA-384/512). + /// + /// Failure cases: + /// - a zero `length` fails `error.invalid-key`; + /// - implementations MAY decline lengths that are not a multiple of 8 + /// with `error.unsupported` (none of this package's implementations + /// serve sub-byte lengths); + /// - implementations MAY apply the same policy bounds as `import-key-raw`; + /// - an unserved variant fails `error.unsupported`. + generate-key: async func(%variant: sha2-variant, length: option, options: mac-key-options) -> result; + + /// Mint a key from a parameterized derivation: the derivation runs + /// at `length` bits and the result is subject to `import-key-raw`'s + /// contract. + /// + /// `length` follows `generate-key`'s contract exactly (WebCrypto's + /// `deriveKey` computes the derived length by the same `get key + /// length` step `generateKey` uses). + /// + /// Requires `can-derive-key` on the input, else `error.not-permitted`; + /// requesting an extractable key additionally requires + /// `can-derive-bits` (see `aes-gcm.derive-key`). + derive-key: async func(%variant: sha2-variant, input: borrow, length: option, options: mac-key-options) -> result; + + /// Mint a key from unwrapped key material (see the `wrapping` + /// interface): `input`'s bytes are read as raw key material, subject + /// to `import-key-raw`'s contract. `input` is consumed. + /// + /// The minted key's usages and extractability come from `options` + /// alone (the W3C Web Cryptography API's `unwrapKey` model). + unwrap-key-raw: async func(%variant: sha2-variant, input: unwrap-input, options: mac-key-options) -> result; + + /// Mint a key from unwrapped key material read as an RFC 7517 JSON + /// Web Key, subject to `import-key-jwk`'s contract plus the + /// unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed; see `unwrap-key-raw` for the + /// options model. + unwrap-key-jwk: async func(%variant: sha2-variant, input: unwrap-input, options: mac-key-options) -> result; +} + +/// HMAC key minting (RFC 2104) over SHA-1 (FIPS 180-4), for +/// interoperability with SHA-1-committed constructions — RFC 6238 TOTP, +/// WPA2's handshake PRF, and their contemporaries. +/// +/// Security: +/// - HMAC-SHA-1 is *not* affected by SHA-1's collision breaks: HMAC's +/// security rests on the compression function's PRF property, which +/// stands. The construction is sound; only its hash is +/// hygiene-stale. Prefer `hmac-sha2` in new designs. +/// - No SHA-1 digest is ever exposed here (the hash runs inside the +/// construction); bare SHA-1 digests exist in this package only through +/// `sha1-checked`. +/// +/// Keys report `algorithm-hash` `"SHA-1"`; every other contract — +/// key-length policy, `length` semantics (the block size, 512 bits, when +/// `none`), JWK handling (`alg` `"HS1"`) — is `hmac-sha2`'s, minus the +/// variant parameter. +interface hmac-sha1 { + use types.{error}; + use mac.{mac-key, mac-key-options}; + use derivation.{derive-input}; + use wrapping.{unwrap-input}; + + /// Import raw key material as an HMAC-SHA-1 key. See + /// `hmac-sha2.import-key-raw` for the key-length contract. + import-key-raw: async func(raw: list, options: mac-key-options) -> result; + + /// Import an RFC 7517 JSON Web Key as an HMAC-SHA-1 key (`alg`, when + /// present, must be `"HS1"`). See `hmac-sha2.import-key-jwk`. + import-key-jwk: async func(jwk: string, options: mac-key-options) -> result; + + /// Generate a fresh random HMAC-SHA-1 key. See + /// `hmac-sha2.generate-key` for the `length` contract; `none` means + /// SHA-1's block size, 512 bits. + generate-key: async func(length: option, options: mac-key-options) -> result; + + /// Mint a key from a parameterized derivation. See + /// `hmac-sha2.derive-key`. + derive-key: async func(input: borrow, length: option, options: mac-key-options) -> result; + + /// Mint a key from unwrapped key material read as raw bytes. See + /// `hmac-sha2.unwrap-key-raw`. + unwrap-key-raw: async func(input: unwrap-input, options: mac-key-options) -> result; + + /// Mint a key from unwrapped key material read as an RFC 7517 JSON + /// Web Key. See `hmac-sha2.unwrap-key-jwk`. + unwrap-key-jwk: async func(input: unwrap-input, options: mac-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/pbkdf2.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/pbkdf2.wit new file mode 100644 index 00000000..d0d63e46 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/pbkdf2.wit @@ -0,0 +1,110 @@ +package polymorph:webcrypto@0.1.0; + +/// PBKDF2 (RFC 8018) passwords: the hash-independent half of the PBKDF2 +/// surface. The `password` resource minted here parameterizes derivations +/// through the per-hash prepare interfaces (`pbkdf2-sha2`, +/// `pbkdf2-sha1`), so one imported password serves either hash family. +/// +/// Security: +/// - No operation returns a `password`'s bytes, under any grant: +/// WebCrypto's forced non-extractability on PBKDF2 base keys, made +/// structural. +/// - The implementation holds an imported password in memory for the +/// resource's construction-to-drop lifetime; that window is the +/// secret's exposure to memory side channels. Import late, drop early. +/// Dropping releases (and, in scrubbing implementations, zeroizes) it. +interface pbkdf2 { + use types.{error}; + use derivation.{derive-options}; + use wrapping.{unwrap-input}; + + /// A password: consumable only by `prepare`, never readable. Usage + /// grants are fixed at import and copied to every input prepared from + /// it. + resource password { + /// Whether inputs prepared from this password may yield raw bits. + can-derive-bits: func() -> bool; + /// Whether inputs prepared from this password may mint keys. + can-derive-key: func() -> bool; + } + + /// Import a password. + /// + /// *Empty* passwords are accepted, unlike `hkdf.import-ikm`: RFC 8018 + /// admits an empty `P` and the platform serves it. Fails + /// `error.not-permitted` if `options` grants nothing, per the + /// package-wide options contract (see `README.md`). + import-password: async func(raw: list, options: derive-options) -> result; + + /// Mint a password from unwrapped bytes (see the `wrapping` + /// interface), subject to `import-password`'s contract. See + /// `hkdf.unwrap-ikm` for the model — the grants come from `options` + /// alone, and non-extractability stays structural. `input` is + /// consumed. + unwrap-password: async func(input: unwrap-input, options: derive-options) -> result; +} + +/// PBKDF2 (RFC 8018) over the SHA-2 hash family: password-based key +/// derivation, as the Web Cryptography API serves it. +/// +/// Like `hkdf-sha2`, this interface mints no keys. It mints +/// `derive-input`s — parameterized derivations — which the key-minting +/// interfaces' `derive-key` functions consume, and which yield raw output +/// through `derivation.derive-input.derive-bits`. The `password` resource +/// and its import stay `pbkdf2`'s, so one imported password can +/// parameterize derivations of either hash family. +/// +/// There is deliberately no `prepare-from` here: adopting another +/// derivation's output as a *password* inverts the primitive's purpose. +/// `hkdf-sha2.prepare-from` is the chaining path. +interface pbkdf2-sha2 { + use types.{error}; + use sha2.{sha2-variant}; + use pbkdf2.{password}; + use derivation.{derive-input}; + + /// Parameterize a derivation: `salt` and the iteration count are + /// bound now, and the output length arrives per use (the platform's + /// own split — `Pbkdf2Params` at `deriveBits`, length from the + /// target's get-key-length at `deriveKey`). + /// + /// Security: + /// - `iterations` is the work factor: it is the only brake on + /// brute-force attack against the password. Use the largest count + /// your latency budget allows. + /// - Use a unique random `salt` per password, so equal passwords do + /// not derive equal keys and precomputed tables do not apply. + /// + /// A zero iteration count fails `error.other`, the platform's + /// `OperationError` (RFC 8018 requires a positive count), checked here + /// rather than at use so a misparameterized input cannot mint. An + /// implementation not serving the variant fails `error.unsupported` + /// (the truncated SHA-2 variants are unserved package-wide). + /// + /// Unlike `hkdf-sha2.prepare`, running the derivation early cannot + /// discard the base secret entirely: PBKDF2 has no extract step, so + /// the input retains password-derived keyed state (the PRF's key + /// schedule) for its lifetime — equivalent in sensitivity to HKDF's + /// retained PRK, and still not the raw password bytes. + prepare: async func(%variant: sha2-variant, input: borrow, salt: list, iterations: u32) -> result; +} + +/// PBKDF2 (RFC 8018) over HMAC-SHA-1, for interoperability with +/// SHA-1-committed derivations — WPA2-PSK, Kerberos RFC 3962 +/// string-to-key, WinZip AE-2, and their contemporaries. The construction +/// is HMAC-based, so SHA-1's collision breaks do not reach it (see +/// `hmac-sha1`'s Security notes); prefer the SHA-2 parameterizations in +/// new designs. +/// +/// This interface adds only the SHA-1 `prepare` step: the `password` +/// resource and its import stay `pbkdf2`'s, so one imported password can +/// parameterize derivations of either hash family. +interface pbkdf2-sha1 { + use types.{error}; + use pbkdf2.{password}; + use derivation.{derive-input}; + + /// Parameterize a PBKDF2-HMAC-SHA-1 derivation. See + /// `pbkdf2-sha2.prepare` for the `salt` and `iterations` contracts. + prepare: async func(input: borrow, salt: list, iterations: u32) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/rsa.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/rsa.wit new file mode 100644 index 00000000..2d24cf01 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/rsa.wit @@ -0,0 +1,330 @@ +package polymorph:webcrypto@0.1.0; + +/// The RSA signature family's shared parameterization and admission +/// contract. Interfaces minting RSA verification keys +/// (`rsassa-pkcs1-v15-verify`, `rsa-pss-verify`) declare their variant +/// from this one set (the `sha2`/`aes` pattern: one definition per +/// family). +/// +/// Key admission, shared by every RSA import: +/// - The modulus length must be 1024–16384 bits inclusive; anything +/// outside fails `error.invalid-key`. The small end of RSA is +/// factorable — 768-bit was publicly factored in 2009 — and a +/// signature that verifies under a factorable key authenticates +/// nothing (see `README.md`, "Design notes"). +/// - The public exponent must be odd and at least 3, or the import +/// fails `error.invalid-key`. `3` and `65537` are guaranteed to +/// import; whether a larger exponent is admitted is +/// implementation-defined — do not rely on either behavior. +/// - The SubjectPublicKeyInfo algorithm must be `rsaEncryption`; a key +/// carrying `id-RSASSA-PSS` parameters fails `error.invalid-key`. +/// - RSA public keys have no raw form (the platform serves `spki` and +/// `jwk` only), so `verifying-key.export-key-raw` fails +/// `error.unsupported` for keys minted by this family. +interface rsa { + /// The mint-bound digests RSA signature verification serves: RSA's + /// parameterization is the digest alone — the modulus length is a + /// property of the imported material, reported by + /// `verifying-key.algorithm-length`. (Each algorithm interface + /// names its closed set of parameterizations `-variant`; + /// the two RSA signature interfaces share this one.) + /// + /// SHA-1 is deliberately absent, though platforms serve it: + /// collision resistance is load-bearing for signature verification, + /// and SHA-1's is broken (see `README.md`, "Design notes"). + enum rsa-variant { + sha256, + sha384, + sha512, + } + + /// The modulus lengths `generate-key` serves: the standard sizes, + /// closed as an enum so a nonstandard or sub-2048 generation request + /// is unrepresentable. (Import admission is wider — see the family + /// contract above and the signing interfaces' window — because + /// existing keys are facts; new keys are choices.) + enum rsa-modulus { + /// 2048 bits: the NIST SP 800-131A floor, and the size the + /// deployed JOSE/DKIM/SAML ecosystems issue. + m2048, + /// 3072 bits (the 128-bit security match). + m3072, + /// 4096 bits. + m4096, + /// 8192 bits. + m8192, + } +} + +/// RSASSA-PKCS1-v1_5 verification-key minting (RFC 8017 §8.2), as the +/// Web Cryptography API serves it. +/// +/// The variant binds the digest at mint — WebCrypto's own model for RSA +/// (`RsaHashedImportParams`). Keys report `algorithm-name` +/// `"RSASSA-PKCS1-v1_5"`, the variant's digest (`algorithm-hash`), and +/// the modulus length (`algorithm-length`). +/// +/// Security: +/// - Verification is strict: the EMSA-PKCS1-v1_5 encoding is compared +/// byte-exact, so a signature carrying BER laxities in its DigestInfo +/// or mis-sized padding fails `error.authentication-failed`. +interface rsassa-pkcs1-v15-verify { + use types.{error}; + use signature.{verifying-key}; + use rsa.{rsa-variant}; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER). + /// Admission follows the family contract (see `rsa`). + import-verifying-key-spki: async func(%variant: rsa-variant, spki: list) -> result; + + /// Import a public key as an RSA public JWK (`kty: "RSA"`, with `n` + /// and `e`). `jwk` is the JWK as JSON text; see + /// `mac-key.export-key-jwk` for the package-wide JWK contract. An + /// `alg` member, when present, must be the variant's JOSE alg + /// (`"RS256"`, `"RS384"`, or `"RS512"`). + import-verifying-key-jwk: async func(%variant: rsa-variant, jwk: string) -> result; +} + +/// RSA-PSS verification-key minting (RFC 8017 §8.1), as the Web +/// Cryptography API serves it. +/// +/// The variant binds the digest at mint (WebCrypto's +/// `RsaHashedImportParams`), and — unlike WebCrypto, whose `saltLength` +/// is a per-operation parameter — the salt length binds at mint too: a +/// granted key verifies exactly one PSS parameterization, so +/// salt-length confusion is unrepresentable (see `README.md`, "Design +/// notes"). The MGF1 digest is the message digest, as WebCrypto fixes +/// it. Keys report `algorithm-name` `"RSA-PSS"`, the variant's digest, +/// and the modulus length. +interface rsa-pss-verify { + use types.{error}; + use signature.{verifying-key}; + use rsa.{rsa-variant}; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER). + /// Admission follows the family contract (see `rsa`). + /// + /// `salt-length` is the PSS salt length in bytes the minted key + /// verifies with; a signature made under any other salt length + /// fails `error.authentication-failed`, like any other + /// non-verifying signature. (JOSE's `PS*` algorithms fix the salt + /// length to the digest length.) + import-verifying-key-spki: async func(%variant: rsa-variant, salt-length: u32, spki: list) -> result; + + /// Import a public key as an RSA public JWK (`kty: "RSA"`, with `n` + /// and `e`); see `import-verifying-key-spki` for `salt-length`, and + /// `mac-key.export-key-jwk` for the package-wide JWK contract. An + /// `alg` member, when present, must be the variant's JOSE alg + /// (`"PS256"`, `"PS384"`, or `"PS512"`). + import-verifying-key-jwk: async func(%variant: rsa-variant, salt-length: u32, jwk: string) -> result; +} + +/// RSASSA-PKCS1-v1_5 signing-key minting (RFC 8017 §8.2). +/// +/// Gated `@unstable(feature = rsa-sign)`: serving RSA private-key +/// operations is a per-deployment judgment — their timing behavior has a +/// long exploitation lineage, and no implementation of them can be +/// verified safe from inside this package — so consumers opt in by +/// naming the gate (see `README.md`, "Stability gates"). +/// +/// Security: +/// - RSA private-key operations leak key material through execution +/// timing unless the implementation is constant-time end to end, +/// including its big-integer arithmetic. Providers in +/// attacker-observable timing domains do not export this interface, +/// and compositions requiring it then fail at composition time (see +/// `README.md`, "Timing-channel policy"). Host-backed providers serve +/// it subject to the gate. +/// +/// Signing-key admission tightens the family window: the modulus must +/// be 2048–8192 bits, `error.invalid-key` outside. Below 2048 is NIST +/// SP 800-131A's 2013 floor — a signing key below it mints signatures +/// no current policy should trust (verification of legacy keys keeps +/// the family's 1024-bit floor); above 8192 serves no deployed system. Keys import as PKCS#8 or an +/// RSA private JWK — the platform pass-through formats — and never as +/// bare components (see `README.md`, "Design notes", the +/// format-admission rule). Imports return only the signing key (the +/// no-derive rule; importers needing the public half supply it to the +/// `-verify` interface). +@unstable(feature = rsa-sign) +interface rsassa-pkcs1-v15-sign { + @unstable(feature = rsa-sign) + use types.{error}; + @unstable(feature = rsa-sign) + use signature.{signing-key, signing-key-options, verifying-key}; + @unstable(feature = rsa-sign) + use rsa.{rsa-variant, rsa-modulus}; + @unstable(feature = rsa-sign) + use wrapping.{unwrap-input}; + + /// Generate a fresh signing key pair, returning both halves — the + /// only point at which every provider is guaranteed to have the + /// public key on hand. The public exponent is 65537; it is not a + /// parameter. + @unstable(feature = rsa-sign) + generate-key: async func(%variant: rsa-variant, modulus: rsa-modulus, options: signing-key-options) -> result, error>; + + /// Import a signing key as a PKCS#8 PrivateKeyInfo (DER, the RFC + /// 8017 RSAPrivateKey body, with its CRT parameters — the form every + /// platform emits). Admission follows the family contract plus the + /// signing window above. + @unstable(feature = rsa-sign) + import-signing-key-pkcs8: async func(%variant: rsa-variant, pkcs8: list, options: signing-key-options) -> result; + + /// Import a signing key as an RSA private JWK (`kty: "RSA"`, with + /// `n`, `e`, `d`, and the CRT members `p`/`q`/`dp`/`dq`/`qi`, which + /// the platforms require of private RSA JWKs). `alg`, when present, + /// must be the variant's JOSE alg, as on the `-verify` interface. + @unstable(feature = rsa-sign) + import-signing-key-jwk: async func(%variant: rsa-variant, jwk: string, options: signing-key-options) -> result; + + /// Mint a signing key from unwrapped key material read as a PKCS#8 + /// PrivateKeyInfo, subject to `import-signing-key-pkcs8`'s contract. + /// `input` is consumed; the minted key's usages and extractability + /// come from `options` alone (the W3C Web Cryptography API's + /// `unwrapKey` model). + @unstable(feature = rsa-sign) + unwrap-signing-key-pkcs8: async func(%variant: rsa-variant, input: unwrap-input, options: signing-key-options) -> result; + + /// Mint a signing key from unwrapped key material read as an RSA + /// private JWK, subject to `import-signing-key-jwk`'s contract plus + /// the unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed. + @unstable(feature = rsa-sign) + unwrap-signing-key-jwk: async func(%variant: rsa-variant, input: unwrap-input, options: signing-key-options) -> result; +} + +/// RSA-PSS signing-key minting (RFC 8017 §8.1). +/// +/// Gated and admission-bounded exactly as `rsassa-pkcs1-v15-sign` (see +/// its interface doc; the same security contract applies). +/// +/// Keys minted here sign with the salt length equal to the digest +/// length — the JOSE `PS*` and RFC 8017 default profile; it is not a +/// parameter. (Verification of foreign signatures under other salt +/// lengths is `rsa-pss-verify`'s parameterized mint.) The signature is +/// not deterministic: PSS salts are random, so signing the same input +/// twice produces different bytes, both of which verify. +@unstable(feature = rsa-sign) +interface rsa-pss-sign { + @unstable(feature = rsa-sign) + use types.{error}; + @unstable(feature = rsa-sign) + use signature.{signing-key, signing-key-options, verifying-key}; + @unstable(feature = rsa-sign) + use rsa.{rsa-variant, rsa-modulus}; + @unstable(feature = rsa-sign) + use wrapping.{unwrap-input}; + + /// See `rsassa-pkcs1-v15-sign.generate-key`. + @unstable(feature = rsa-sign) + generate-key: async func(%variant: rsa-variant, modulus: rsa-modulus, options: signing-key-options) -> result, error>; + + /// See `rsassa-pkcs1-v15-sign.import-signing-key-pkcs8`. + @unstable(feature = rsa-sign) + import-signing-key-pkcs8: async func(%variant: rsa-variant, pkcs8: list, options: signing-key-options) -> result; + + /// See `rsassa-pkcs1-v15-sign.import-signing-key-jwk`. + @unstable(feature = rsa-sign) + import-signing-key-jwk: async func(%variant: rsa-variant, jwk: string, options: signing-key-options) -> result; + + /// See `rsassa-pkcs1-v15-sign.unwrap-signing-key-pkcs8`. + @unstable(feature = rsa-sign) + unwrap-signing-key-pkcs8: async func(%variant: rsa-variant, input: unwrap-input, options: signing-key-options) -> result; + + /// See `rsassa-pkcs1-v15-sign.unwrap-signing-key-jwk`. + @unstable(feature = rsa-sign) + unwrap-signing-key-jwk: async func(%variant: rsa-variant, input: unwrap-input, options: signing-key-options) -> result; +} + +/// RSA-OAEP encryption-key minting (RFC 8017 §7.1), as the Web +/// Cryptography API serves it: the public half of key transport. +/// +/// The variant binds the digest at mint (WebCrypto's +/// `RsaHashedImportParams`); the MGF1 digest is the same digest, as +/// WebCrypto fixes it. Keys report `algorithm-name` `"RSA-OAEP"`, the +/// variant's digest, and the modulus length. +/// +/// Admission tightens the family window on both ends: the modulus must +/// be 2048–8192 bits, `error.invalid-key` outside — encryption creates +/// *future* artifacts, so unlike signature verification there is no +/// legacy tier below 2048 (see `README.md`, "Design notes"). The +/// plaintext bound follows from the mint: modulus bytes minus twice the +/// digest length minus 2. +interface rsa-oaep-encrypt { + use types.{error}; + use public-encryption.{encryption-key}; + use rsa.{rsa-variant}; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER). + /// Admission follows the family contract (see `rsa`) plus the + /// window above. + import-encryption-key-spki: async func(%variant: rsa-variant, spki: list) -> result; + + /// Import a public key as an RSA public JWK (`kty: "RSA"`, with `n` + /// and `e`). An `alg` member, when present, must be the variant's + /// JOSE alg (`"RSA-OAEP-256"`, `"RSA-OAEP-384"`, or + /// `"RSA-OAEP-512"`). See `mac-key.export-key-jwk` for the + /// package-wide JWK contract. + import-encryption-key-jwk: async func(%variant: rsa-variant, jwk: string) -> result; +} + +/// RSA-OAEP decryption-key minting (RFC 8017 §7.1): the private half of +/// key transport. +/// +/// Gated `@unstable(feature = rsa-oaep-decrypt)`: RSA private-key +/// operations are a per-deployment timing judgment the package cannot +/// make or verify from inside — and decryption is the operation the +/// attack lineage targets — so consumers opt in by naming the gate (see +/// `README.md`, "Stability gates"; the same consent contract as +/// `rsa-sign`). +/// +/// Security: +/// - Providers in attacker-observable timing domains do not export this +/// interface, and compositions requiring it fail at composition time +/// (see `README.md`, "Timing-channel policy"). Host-backed providers +/// serve it subject to the gate. +/// +/// Admission is `rsa-oaep-encrypt`'s (2048–8192 bits). Keys import as +/// PKCS#8 or a full-CRT RSA private JWK, exactly as `rsassa-pkcs1-v15-sign` +/// documents, and imports return only the decryption key (the no-derive +/// rule). +@unstable(feature = rsa-oaep-decrypt) +interface rsa-oaep-decrypt { + @unstable(feature = rsa-oaep-decrypt) + use types.{error}; + @unstable(feature = rsa-oaep-decrypt) + use public-encryption.{encryption-key, decryption-key, decryption-key-options}; + @unstable(feature = rsa-oaep-decrypt) + use rsa.{rsa-variant, rsa-modulus}; + @unstable(feature = rsa-oaep-decrypt) + use wrapping.{unwrap-input}; + + /// Generate a fresh key pair, returning both halves — the only + /// point at which every provider is guaranteed to have the public + /// key on hand. The public exponent is 65537; it is not a parameter. + @unstable(feature = rsa-oaep-decrypt) + generate-key: async func(%variant: rsa-variant, modulus: rsa-modulus, options: decryption-key-options) -> result, error>; + + /// Import a decryption key as a PKCS#8 PrivateKeyInfo (DER, with + /// CRT parameters). + @unstable(feature = rsa-oaep-decrypt) + import-decryption-key-pkcs8: async func(%variant: rsa-variant, pkcs8: list, options: decryption-key-options) -> result; + + /// Import a decryption key as a full-CRT RSA private JWK. `alg`, + /// when present, follows `import-encryption-key-jwk`'s values. + @unstable(feature = rsa-oaep-decrypt) + import-decryption-key-jwk: async func(%variant: rsa-variant, jwk: string, options: decryption-key-options) -> result; + + /// Mint a decryption key from unwrapped material read as a PKCS#8 + /// PrivateKeyInfo. `input` is consumed; grants and extractability + /// come from `options` alone (the W3C `unwrapKey` model). + @unstable(feature = rsa-oaep-decrypt) + unwrap-decryption-key-pkcs8: async func(%variant: rsa-variant, input: unwrap-input, options: decryption-key-options) -> result; + + /// Mint a decryption key from unwrapped material read as an RSA + /// private JWK, subject to the unwrap-path `use`/`key_ops` checks + /// (see `README.md`, "JWK contract"). `input` is consumed. + @unstable(feature = rsa-oaep-decrypt) + unwrap-decryption-key-jwk: async func(%variant: rsa-variant, input: unwrap-input, options: decryption-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/sha1.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/sha1.wit new file mode 100644 index 00000000..c23c9c45 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/sha1.wit @@ -0,0 +1,66 @@ +package polymorph:webcrypto@0.1.0; + +/// SHA-1 digests with collision-attack detection (`sha1-checked`, after +/// the RustCrypto packaging of the Stevens–Karpman counter-cryptanalysis +/// that Git ships as sha1dc). +/// +/// This package never serves a *plain* SHA-1 digest (the HMAC-SHA-1 +/// family runs the hash inside its construction, where collision +/// resistance is not load-bearing and no digest is exposed). Both +/// constructors mint a +/// `digest` whose `compute` runs standard SHA-1 *plus* detection of the +/// disturbance-vector patterns every known collision attack requires, and +/// each constructor fixes what happens when detection fires. On honest +/// input — input carrying no attack pattern — both postures return the +/// standard SHA-1 digest, byte-identical to any other SHA-1 +/// implementation. +/// +/// Both postures are deterministic: two parties hashing the same input +/// always agree on the outcome. Keys minted here report `algorithm-name` +/// `"SHA-1"`. +/// +/// Security: +/// - SHA-1 is broken for collision resistance. Use this interface for +/// interoperability with SHA-1-committed formats (Git-family content +/// addressing, legacy protocols), never in new designs — `sha2` is the +/// default. +/// - Detection is one-sided: it recognizes the known attack families' +/// patterns. An honest-input digest is exactly SHA-1, with SHA-1's +/// (broken) guarantees. +/// - Detection branches only on the input, which this interface treats as +/// public (the digest kind is unkeyed), so providers in shared timing +/// domains can serve it (see `README.md`, "Timing-channel policy"). +/// +/// Gated `@unstable`: platform WebCrypto carries no sha1dc, so +/// platform-backed providers cannot serve this interface (see +/// `README.md`, "Stability gates"). Consumers opt in by enabling the +/// feature in their tooling. +@unstable(feature = sha1-checked) +interface sha1-checked { + @unstable(feature = sha1-checked) + use types.{error}; + @unstable(feature = sha1-checked) + use digest.{digest}; + + /// Mint a SHA-1 digest that *rejects* on a detected collision attack: + /// `compute` fails with `error.extension` — origin `"polymorph:webcrypto"`, + /// name `"collision-detected"` — for input carrying an attack + /// pattern, and returns the standard SHA-1 digest for everything + /// else. Choose this posture to surface an attack as a diagnosable + /// error. + @unstable(feature = sha1-checked) + make-rejecting-digest: func() -> result; + + /// Mint a SHA-1 digest that *mitigates* a detected collision attack: + /// for input carrying an attack pattern, `compute` returns the + /// sha1dc safe hash — a deterministic hardened variant the attack was + /// not engineered against — and the standard SHA-1 digest for + /// everything else. Two parties hashing the same attacked input agree + /// on its safe hash, so pipelines keep flowing while the colliding + /// pair no longer collides. Choose this posture for content + /// addressing, where an error would wedge the pipeline; the cost is + /// that an attack surfaces as an external hash mismatch rather than a + /// named error. + @unstable(feature = sha1-checked) + make-mitigating-digest: func() -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/sha2.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/sha2.wit new file mode 100644 index 00000000..22eb1463 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/sha2.wit @@ -0,0 +1,34 @@ +package polymorph:webcrypto@0.1.0; + +/// SHA-2 digest minting (FIPS 180-4), and the home of the `sha2-variant` +/// enum every SHA-2-parameterized interface shares (`hmac-sha2` uses it +/// too). +/// +/// Digests returned here drive `digest.digest.compute`. +interface sha2 { + use types.{error}; + use digest.{digest}; + + /// The SHA-2 variants: FIPS 180-4 closes this set, so it is an enum. + /// (Each algorithm interface names its closed set of parameterizations + /// `-variant`.) + enum sha2-variant { + /// Not widely supported: WebCrypto serves only SHA-256/384/512, and + /// no current implementation of this package serves the truncated + /// variants — expect `error.unsupported` unless composed with a + /// provider that specifically offers them. + sha224, + sha256, + sha384, + sha512, + /// Not widely supported (see `sha224`). + sha512-224, + /// Not widely supported (see `sha224`). + sha512-256, + } + + /// A `digest` bound to the declared SHA-2 variant. Fails with + /// `error.unsupported` if this implementation does not serve the + /// variant. + make-digest: func(%variant: sha2-variant) -> result; +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/webcrypto.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/webcrypto.wit new file mode 100644 index 00000000..3780741c --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/webcrypto.wit @@ -0,0 +1,747 @@ +package polymorph:webcrypto@0.1.0; + +/// Shared structural types for the WebCrypto-style interfaces. +/// +/// Every non-resource type used by the package lives here. The stateful +/// objects (keys) live in the per-primitive-kind interfaces (`mac`, `aead`, +/// …), because a resource is always owned by the one component that +/// implements it. +/// +/// See `README.md` in this package for the package-wide contracts and +/// terminology. +interface types { + /// A named error condition defined outside `error`'s closed set: by an + /// algorithm interface's contract (the first is `sha1-checked`'s + /// `collision-detected`), a future feature's, or a third-party + /// provider's. The (`origin`, `name`) pair is the condition's only + /// branchable identity; the defining interface documents when the + /// condition occurs. Conditions are nominal: the pair identifies, the + /// message elaborates for humans, and no field carries machine-readable + /// data. See `README.md`, "Error contract", including the rule for + /// pairs a caller does not recognize. + record extension-error { + /// The namespace the condition is defined under: an opaque string + /// owned by the defining party, by convention its package name, + /// e.g. `"polymorph:webcrypto"`. Ownership means names cannot collide: + /// third-party providers mint conditions under their own + /// namespace, never under another party's. + origin: string, + /// The condition's name within `origin`, kebab-case by convention, + /// e.g. `"collision-detected"`. + name: string, + /// Human-readable prose for logs and diagnostics only. Never + /// branch on it: its content is not contract and may differ + /// across providers. Empty where the condition's contract is + /// deliberately detail-free. + message: string, + } + + /// Errors surfaced by key creation and cryptographic operations. + /// + /// There is no case for API misuse: the package aims to make misuse + /// inexpressible instead of reporting it (see `README.md`, "Design + /// notes"). See also `README.md`, "Error contract". + variant error { + /// The supplied key material is invalid for the algorithm (for + /// example, a wrong-length raw key, or one rejected by an + /// implementation's key-length policy). The string is + /// human-readable. + invalid-key(string), + /// The supplied nonce is invalid for the algorithm (for example, a + /// wrong-length AES-GCM nonce). The string is human-readable. + invalid-nonce(string), + /// Verification failed: the input is unauthenticated under the key. + /// + /// Security: + /// - Failed verification MUST report this case and nothing else. + /// Security telemetry may rely on it never being misfiled. + /// - The case deliberately carries no detail, so implementations + /// cannot leak *why* verification failed. + /// - Rare operational false positives are possible on `open`, + /// never on `verify`. See `README.md`, "Error contract". + authentication-failed, + /// The key was created with `extractable` false, so its material + /// cannot be exported. + not-extractable, + /// The request was well-formed, but this implementation does not + /// serve the requested algorithm parameters (for example, a key + /// size the platform's crypto API declines). The string is + /// human-readable. + unsupported(string), + /// The key does not permit the requested operation: the operation's + /// usage was not granted at mint. The string names the refused + /// operation. Distinct from `unsupported` (the implementation + /// serves the operation; this key refuses it) and never a verdict + /// on the input. + not-permitted(string), + /// An implementation-specific operational failure: for example, an + /// external keystore that cannot complete the operation now, or an + /// input exceeding an implementation's buffering limit. The string + /// is human-readable. + /// Never a semantic condition a caller must branch on: a + /// condition that turns out to need branching migrates to a named + /// `extension` pair (see `README.md`, "Error contract"). + other(string), + /// A named condition outside this variant's closed set (see + /// `extension-error`). Unlike `other`, callers may branch on it: + /// the defining interface's contract says when it occurs. See + /// `README.md`, "Error contract", for which conditions belong in + /// the closed cases and which here. + extension(extension-error), + } +} + +/// The `mac` primitive kind: message-authentication codes. +/// +/// This interface is algorithm-agnostic. Per-algorithm interfaces (such as +/// `hmac-sha2`) mint `mac-key` resources bound to their algorithm; every +/// operation hangs off the key. Key handles are capabilities: a world that +/// imports only this interface can use keys it is granted, but cannot mint +/// them (see `README.md`). +interface mac { + use types.{error}; + use wrapping.{wrap-input}; + + /// Mint-time policy for a `mac-key`, consumed by the minting + /// interfaces' functions. Grants nothing by default; see `README.md`, + /// "Key-options contract". + resource mac-key-options { + constructor(); + /// Whether the minted key may `sign`. Disabled by default. + can-sign: func(allowed: bool); + /// Whether the minted key may `verify`. Disabled by default. + can-verify: func(allowed: bool); + /// Whether the minted key's material may be exported. Disabled by + /// default. + extractable: func(allowed: bool); + } + + /// A MAC key: an unforgeable capability, bound to one algorithm at + /// creation. + /// + /// `sign` and `verify` are one-shot and stateless per call, mirroring + /// `crypto.subtle.sign`/`verify`. The byte `stream` is the only + /// ingestion path; the result is chunking-invariant. + /// + /// Security: + /// - A closed input stream carries no verdict: an operation computes + /// correctly over whatever prefix a failed producer delivered. See + /// `README.md`, "Streaming contract" (truncating producers). + /// + /// Callers with several operations in flight must feed and drain them + /// concurrently, or they can deadlock; see `README.md`, "Streaming + /// contract" (making progress, returned streams). + /// + /// For the meaning of `extractable` and the getter conventions, see + /// `README.md`, "Extractability" and "Getter conventions". + resource mac-key { + /// Compute the authentication tag over an entire byte stream keyed + /// by this key. A success resolves only after the stream is fully + /// drained (its writer dropped). + /// + /// Fails only for operational reasons (`error.other`). + sign: async func(data: stream) -> result, error>; + + /// Verify `tag` against the tag computed over an entire byte stream + /// keyed by this key, in constant time. Both verdicts — `ok` and + /// `error.authentication-failed` — are computed over the entire + /// stream and resolve only after it is fully drained. + /// + /// Fails with `error.authentication-failed` if the tag does not + /// verify. Returns a `result`, not a `bool`: an ignored boolean + /// fails open, a dropped `result` does not. + verify: async func(data: stream, tag: list) -> result<_, error>; + + /// The registry name of the algorithm family this key is bound to, + /// e.g. `"HMAC"` (WebCrypto's `KeyAlgorithm.name`). The algorithm's + /// parameters are separate getters (`algorithm-hash`, + /// `algorithm-length`), so future parameters are new getters rather + /// than changes to a shared type. + algorithm-name: func() -> string; + + /// The registry name of the digest this key's algorithm is + /// parameterized over, if any: e.g. `"SHA-256"` for HMAC-SHA-256 + /// (WebCrypto's `HmacKeyAlgorithm.hash`). `none` for MAC algorithms + /// not built on a digest. + algorithm-hash: func() -> option; + + /// The key length in bits (WebCrypto's `HmacKeyAlgorithm.length`). + algorithm-length: func() -> u32; + + /// Whether the key material may be exported. + extractable: func() -> bool; + + /// Whether this key permits `sign`. A refused operation fails + /// `error.not-permitted`. + can-sign: func() -> bool; + + /// Whether this key permits `verify`. See `can-sign`. + can-verify: func() -> bool; + + /// The raw key material. Fails with `error.not-extractable` unless + /// the key was created with `extractable` true. + export-key-raw: async func() -> result, error>; + + /// The key as an RFC 7517 JSON Web Key, behind the same + /// extractability gate as `export-key-raw`. See `README.md`, + /// "JWK contract". + export-key-jwk: async func() -> result; + + /// This key's raw material as a `wrap-input`, for wrapping + /// under another key (see the `wrapping` interface). Behind the + /// same extractability gate as `export-key-raw`; the material + /// itself never reaches the caller. + to-wrap-input-raw: async func() -> result; + + /// The JWK serialization as a `wrap-input`, behind the same + /// gate. See `README.md`, "JWK contract". + to-wrap-input-jwk: async func() -> result; + } +} + +/// The `aead` primitive kind: single-message authenticated encryption with +/// associated data. +/// +/// This is deliberately a *single-message* primitive, mirroring +/// `crypto.subtle.encrypt`/`decrypt`: one nonce, one tag, and memory +/// proportional to the message somewhere in the implementation. The bulk +/// data paths use streaming transport, but `open` never releases unverified +/// plaintext, so it buffers the whole message internally before its result +/// resolves. Content too large for that contract is outside this kind's +/// scope; the contract is never relaxed to stream unverified plaintext. +/// +/// `seal` and `open` are stateless per call; the key resource carries the +/// operations directly. +interface aead { + use types.{error}; + use wrapping.{wrap-input, unwrap-input}; + + /// Mint-time policy for an `aead-key`. Grants nothing by default; see + /// `README.md`, "Key-options contract". + resource aead-key-options { + constructor(); + /// Whether the minted key may `seal`. Disabled by default. + can-seal: func(allowed: bool); + /// Whether the minted key may `open`. Disabled by default. + can-open: func(allowed: bool); + /// Whether the minted key may `wrap` key material. Disabled by + /// default. + can-wrap: func(allowed: bool); + /// Whether the minted key may `unwrap` key material (see + /// `can-wrap`). Disabled by default. + can-unwrap: func(allowed: bool); + /// Whether the minted key's material may be exported. Disabled by + /// default. + extractable: func(allowed: bool); + } + + /// An AEAD key: an unforgeable capability, bound to one algorithm at + /// creation. The streaming, extractability, and getter contracts in + /// `README.md` apply. + resource aead-key { + /// Encrypt and authenticate `plaintext` under `nonce` with the + /// associated data `aad`. The returned stream carries the + /// ciphertext followed by the authentication tag (the + /// `crypto.subtle.encrypt` wire format). + /// + /// Security: + /// - The caller owns nonce uniqueness per key. Nonce reuse defeats + /// the algorithm's guarantees. + /// - Short tags weaken the forgery bound. They are opt-in: a + /// `tag-size` of `none` selects the algorithm's default (the + /// `tag-size` getter). + /// - The streamed output is not atomic: a producer that fails + /// after the stream is returned ends it early, and a closed + /// stream carries no verdict. A consumer that cannot bound the + /// expected length from its own knowledge of the plaintext must + /// convey completeness in-band. See `README.md`, "Streaming + /// contract". + /// + /// The algorithm defines which nonce lengths and tag sizes it + /// accepts; its minting interface documents both, and the getters' + /// values are always accepted. A nonce of an unaccepted length + /// fails `error.invalid-nonce`; a tag size outside the algorithm's + /// set, or one an implementation's security policy declines, fails + /// `error.unsupported`. Callers wanting the standard behavior pass + /// the getters' sizes and `none`, and never need to match on + /// `algorithm-name`. + /// + /// Implementations may produce the output incrementally (tag last) + /// or buffer and emit it whole. Drain the returned stream + /// concurrently with feeding `plaintext`: an incremental producer + /// may block on unread output before it has taken the whole input. + seal: async func(nonce: list, aad: list, tag-size: option, plaintext: stream) -> result, error>; + + /// Decrypt and verify `ciphertext` (ciphertext followed by a + /// `tag-size`-byte tag, as produced by `seal`) under `nonce` and + /// `aad`. See `seal` for the `nonce` and `tag-size` contracts. + /// + /// Security: + /// - `ok(stream)` resolves only after the ciphertext stream is + /// fully drained and the tag verified: it *is* the + /// authentication statement, and unverified plaintext is never + /// observable. + /// - Fails with `error.authentication-failed` if verification + /// fails. + open: async func(nonce: list, aad: list, tag-size: option, ciphertext: stream) -> result, error>; + + /// Encrypt and authenticate serialized key material under `nonce` + /// with the associated data `aad`, exactly as `seal` encrypts a + /// message: the result is ciphertext followed by tag — for + /// raw-format material, byte-identical to sealing the exported + /// bytes (the W3C Web Cryptography API's `wrapKey`). See `seal` + /// for the `nonce` and `tag-size` contracts; see the `wrapping` + /// interface for the model. + /// + /// Security: + /// - The caller owns nonce uniqueness across `seal` and `wrap` + /// alike: both draw on the same key's nonce space. + /// + /// `input` is consumed. Requires `can-wrap`, else + /// `error.not-permitted`. + wrap: async func(nonce: list, aad: list, tag-size: option, input: wrap-input) -> result, error>; + + /// Decrypt and verify wrapped key material, as produced by `wrap` + /// (or by `seal` over the same serialization) under `nonce` and + /// `aad`. See `seal` for the `nonce` and `tag-size` contracts. + /// The result awaits an unwrap mint (see `unwrap-input`, + /// including the verification-timing latitude); the material + /// never reaches the caller. + /// + /// Security: + /// - Verification fails with `error.authentication-failed` — + /// here, or at the consuming mint when deferred; no mint + /// succeeds on an unverified input. + /// + /// Requires `can-unwrap`, else `error.not-permitted`. + unwrap: async func(nonce: list, aad: list, tag-size: option, wrapped: list) -> result; + + /// The registry name of the algorithm family this key is bound to, + /// e.g. `"AES-GCM"` (WebCrypto's `KeyAlgorithm.name`). Parameters + /// are separate getters, as for `mac-key.algorithm-name`. + algorithm-name: func() -> string; + + /// The key length in bits, e.g. `256` for AES-256-GCM (WebCrypto's + /// `AesKeyAlgorithm.length`). + algorithm-length: func() -> u32; + + /// The algorithm's standard nonce size in bytes, e.g. `12` for + /// AES-GCM. This key's `seal`/`open` always accept it, so a + /// component granted only the key handle can construct nonces + /// without matching on `algorithm-name`. Whether *other* lengths + /// are accepted is the algorithm's contract, documented by its + /// minting interface. + nonce-size: func() -> u32; + + /// The algorithm's default tag size in bytes, e.g. `16`: what a + /// `tag-size` of `none` selects, and the framing arithmetic for + /// default-tag messages (sealed length = plaintext length + + /// `tag-size`). + tag-size: func() -> u32; + + /// Whether the key material may be exported. + extractable: func() -> bool; + + /// Whether this key permits `seal`. A refused operation fails + /// `error.not-permitted`. + can-seal: func() -> bool; + + /// Whether this key permits `open`. See `can-seal`. + can-open: func() -> bool; + + /// Whether this key permits `wrap`. A refused operation fails + /// `error.not-permitted`. + can-wrap: func() -> bool; + + /// Whether this key permits `unwrap`. See `can-wrap`. + can-unwrap: func() -> bool; + + /// The raw key material. Fails with `error.not-extractable` unless + /// the key was created with `extractable` true. + export-key-raw: async func() -> result, error>; + + /// The key as an RFC 7517 JSON Web Key, behind the same + /// extractability gate as `export-key-raw`. See `README.md`, + /// "JWK contract". Algorithms with no registered JWK form + /// fail `error.unsupported`. + export-key-jwk: async func() -> result; + + /// This key's raw material as a `wrap-input`, for wrapping + /// under another key (see the `wrapping` interface). Behind the + /// same extractability gate as `export-key-raw`; the material + /// itself never reaches the caller. + to-wrap-input-raw: async func() -> result; + + /// The JWK serialization as a `wrap-input`, behind the same + /// gate; algorithms with no registered JWK form fail + /// `error.unsupported`, as on `export-key-jwk`. + to-wrap-input-jwk: async func() -> result; + } +} + +/// The `cipher` primitive kind: unauthenticated symmetric encryption +/// (confidentiality only), served for compatibility with +/// WebCrypto-committed formats. See `README.md`, "Design notes", +/// "Unauthenticated modes are in, for compatibility". +/// +/// Security: +/// - Nothing here authenticates. Ciphertext is malleable — an attacker +/// can make targeted edits to the decrypted plaintext without the key — +/// and a successful `decrypt` is not evidence the ciphertext is +/// untampered. Default to the `aead` kind; use this one only where an +/// existing format fixes the mode. +/// - `decrypt` failures are deliberately uniform: every malformed-input +/// condition (for AES-CBC, a bad final padding block among them) fails +/// `error.other` with no distinguishing detail. Implementations MUST +/// NOT reveal *why* a decryption failed — a distinguishable padding +/// verdict is a padding-oracle amplifier. +/// - IV discipline is the algorithm's contract (unpredictability for CBC, +/// per-key uniqueness for CTR counter blocks); the minting interface +/// documents it. The caller owns it, as with `aead` nonces. +interface cipher { + use types.{error}; + use wrapping.{wrap-input, unwrap-input}; + + /// Mint-time policy for a `cipher-key`. Grants nothing by default; see + /// `README.md`, "Key-options contract". + resource cipher-key-options { + constructor(); + /// Whether the minted key may `encrypt`. Disabled by default. + can-encrypt: func(allowed: bool); + /// Whether the minted key may `decrypt`. Disabled by default. + can-decrypt: func(allowed: bool); + /// Whether the minted key may `wrap` key material. Disabled by + /// default. + can-wrap: func(allowed: bool); + /// Whether the minted key may `unwrap` key material (see + /// `can-wrap`). Disabled by default. + can-unwrap: func(allowed: bool); + /// Whether the minted key's material may be exported. Disabled by + /// default. + extractable: func(allowed: bool); + } + + /// An unauthenticated-cipher key: an unforgeable capability, bound to + /// one algorithm at creation. The streaming, extractability, and + /// getter contracts in `README.md` apply, and so do this interface's + /// Security notes — nothing this key does authenticates. + resource cipher-key { + /// Encrypt `plaintext` under `iv`. The returned stream carries + /// exactly the ciphertext (the `crypto.subtle.encrypt` wire + /// format; for padded modes that includes the final padding + /// block). + /// + /// `iv` must be the algorithm's block-sized IV (the `iv-size` + /// getter), else `error.invalid-nonce`. `counter-length` is the + /// counter width in bits for counter-mode algorithms (AES-CTR: + /// required, 1 to 128); algorithms without a counter reject a + /// supplied value, and counter-mode algorithms reject `none` — + /// both `error.invalid-nonce`. Callers of non-counter algorithms + /// pass `none` and never match on `algorithm-name`. + /// + /// Security: + /// - The caller owns the IV discipline (see the interface doc and + /// the minting interface). Getting it wrong loses + /// confidentiality, not just integrity. + encrypt: async func(iv: list, counter-length: option, plaintext: stream) -> result, error>; + + /// Decrypt `ciphertext` under `iv`. See `encrypt` for the `iv` + /// and `counter-length` contracts. + /// + /// Security: + /// - The plaintext is unauthenticated: treat it as + /// attacker-influenced data even when decryption succeeds. + /// - Malformed input fails `error.other`, deliberately uniform + /// across conditions (see the interface doc). + decrypt: async func(iv: list, counter-length: option, ciphertext: stream) -> result, error>; + + /// Encrypt serialized key material under `iv`, exactly as + /// `encrypt` encrypts a message: for raw-format material the + /// result is byte-identical to encrypting the exported bytes + /// (the W3C Web Cryptography API's `wrapKey` under an + /// encryption algorithm). See `encrypt` for the `iv` and + /// `counter-length` contracts; see the `wrapping` interface for + /// the model. + /// + /// Security: + /// - Nothing here authenticates, and the caller owns the IV + /// discipline, as on `encrypt`. Prefer an `aead` or `kw-key` + /// wrapping key; use this one only where an existing format + /// fixes the mode. + /// - `wrap` and `encrypt` draw on the same key's IV space: the + /// algorithm's uniqueness obligations (per-key uniqueness of + /// CTR counter blocks above all) span both operations. + /// + /// `input` is consumed. Requires `can-wrap`, else + /// `error.not-permitted`. + wrap: async func(iv: list, counter-length: option, input: wrap-input) -> result, error>; + + /// Decrypt wrapped key material, as produced by `wrap` (or by + /// `encrypt` over the same serialization) under `iv`. See + /// `encrypt` for the `iv` and `counter-length` contracts. The + /// result awaits an unwrap mint (see `unwrap-input`); the + /// material never reaches the caller. + /// + /// Security: + /// - The result is *unauthenticated*: a success is no evidence + /// the wrapped material is untampered, and the typed mint's + /// parse is not authentication. Malformed input fails + /// `error.other`, deliberately uniform across conditions (see + /// the interface doc) — here, or at the consuming mint when + /// decryption is deferred (see `unwrap-input`). + /// + /// Requires `can-unwrap`, else `error.not-permitted`. + unwrap: async func(iv: list, counter-length: option, wrapped: list) -> result; + + /// The registry name of the algorithm family this key is bound + /// to, e.g. `"AES-CBC"` (WebCrypto's `KeyAlgorithm.name`). + /// Parameters are separate getters, as for + /// `mac-key.algorithm-name`. + algorithm-name: func() -> string; + + /// The key length in bits, e.g. `256` (WebCrypto's + /// `AesKeyAlgorithm.length`). + algorithm-length: func() -> u32; + + /// The algorithm's IV size in bytes, e.g. `16` for the AES modes + /// (the CBC IV, or the CTR initial counter block). `encrypt` and + /// `decrypt` accept exactly this length. + iv-size: func() -> u32; + + /// Whether the key material may be exported. + extractable: func() -> bool; + + /// Whether this key permits `encrypt`. A refused operation fails + /// `error.not-permitted`. + can-encrypt: func() -> bool; + + /// Whether this key permits `decrypt`. See `can-encrypt`. + can-decrypt: func() -> bool; + + /// Whether this key permits `wrap`. A refused operation fails + /// `error.not-permitted`. + can-wrap: func() -> bool; + + /// Whether this key permits `unwrap`. See `can-wrap`. + can-unwrap: func() -> bool; + + /// The raw key material. Fails with `error.not-extractable` unless + /// the key was created with `extractable` true. + export-key-raw: async func() -> result, error>; + + /// The key as an RFC 7517 JSON Web Key, behind the same + /// extractability gate as `export-key-raw`. See `README.md`, + /// "JWK contract". + export-key-jwk: async func() -> result; + + /// This key's raw material as a `wrap-input`, for wrapping + /// under another key (see the `wrapping` interface). Behind the + /// same extractability gate as `export-key-raw`; the material + /// itself never reaches the caller. + to-wrap-input-raw: async func() -> result; + + /// The JWK serialization as a `wrap-input`, behind the same + /// gate. See `README.md`, "JWK contract". + to-wrap-input-jwk: async func() -> result; + } +} + +/// The `digest` primitive kind: cryptographic hash functions. +/// +/// `mac` minus keys: there is no key material, so no extractability and no +/// export. The stateful object is the `digest` resource — an algorithm-bound +/// capability minted by per-algorithm interfaces (such as `sha2`) — so a +/// component whose world imports only this interface can use any digest it +/// is granted but cannot choose algorithms. +interface digest { + use types.{error}; + + /// A digest algorithm, bound at creation. + /// + /// `compute` is one-shot and stateless per call (the resource is + /// reusable), mirroring `crypto.subtle.digest`. The byte `stream` is + /// the only ingestion path; the result is chunking-invariant. The + /// streaming contract in `README.md` applies. + /// + /// Security: + /// - A digest authenticates nothing by itself. There is deliberately + /// no `verify` here: checking untrusted data against a known digest + /// is an *integrity* comparison, done by the caller with a + /// constant-time byte comparison (see `README.md`, "Terminology") + /// of `compute`'s result. When + /// authenticity is needed, use a `mac-key`. + resource digest { + /// Digest an entire byte stream. A success resolves only after the + /// stream is fully drained (its writer dropped). + /// + /// Fails only for operational reasons (`error.other`). + compute: async func(data: stream) -> result, error>; + + /// The registry name of the algorithm this resource is bound to, + /// e.g. `"SHA-256"` (as `crypto.subtle.digest` spells it). For a + /// digest the algorithm *is* the hash, so there is no separate + /// `algorithm-hash` getter. + algorithm-name: func() -> string; + } +} + +/// The `signature` primitive kind: asymmetric signing and verification. +/// +/// This interface is algorithm-agnostic. Per-algorithm interfaces mint keys +/// bound to their algorithm; every operation hangs off a key resource. +/// +/// The public and private halves are distinct resource types, split by +/// secrecy: a component holding only a `verifying-key` provably cannot +/// sign. Minting is likewise split into per-algorithm `-verify` and `-sign` +/// interfaces, so a provider can serve verification for an algorithm whose +/// signing it declines to host (see `README.md`, "Timing-channel policy"). +/// +/// There is deliberately no way to derive a `verifying-key` from a +/// `signing-key`: `generate-key` returns the pair, and importers supply the +/// public half via `import-verifying-key-raw`. See `README.md`, "Design notes". +/// +/// The streaming contract in `README.md` applies to `sign` and `verify`. +interface signature { + use types.{error}; + use wrapping.{wrap-input}; + + /// Mint-time policy for a `signing-key`. Grants nothing by default; + /// see `README.md`, "Key-options contract". `can-sign` is the sole + /// usage today (the public/private resource split already separates + /// sign from verify structurally); the resource carries `extractable` + /// and receives future private-key usages additively. + resource signing-key-options { + constructor(); + /// Whether the minted key may `sign`. Disabled by default, and it + /// is the sole usage, so it must be enabled for the mint to + /// succeed (an untouched options resource fails + /// `error.not-permitted`). + can-sign: func(allowed: bool); + /// Whether the minted key's material may be exported + /// (`signing-key.export-key-jwk`/`-pkcs8` and the + /// `to-wrap-input-*` functions). Disabled by default. + extractable: func(allowed: bool); + } + + /// A public key: verification only, secret-free. + resource verifying-key { + /// Verify `sig` over an entire byte stream. Like `mac-key.verify`, + /// both verdicts are computed over the entire stream and resolve + /// only after it is fully drained, and it fails closed with + /// `error.authentication-failed` (a `result`, not a `bool`: an + /// ignored boolean fails open). + /// + /// Security: + /// - Signature verification is a *policy*, not a bit-exact + /// predicate: the key's minting interface defines the precise + /// criterion — which degenerate keys and signatures must be + /// rejected — exactly as it defines the wire format. + verify: async func(data: stream, sig: list) -> result<_, error>; + + /// The registry name of the algorithm family this key is bound to, + /// e.g. `"Ed25519"` or `"ECDSA"` (WebCrypto's `KeyAlgorithm.name`). + algorithm-name: func() -> string; + + /// The registry name of the curve for algorithms parameterized by + /// one, e.g. `"P-256"` (WebCrypto's `EcKeyAlgorithm.namedCurve`). + /// `none` for Ed25519, whose curve is implied by the name. + algorithm-curve: func() -> option; + + /// The digest bound at mint, e.g. `"SHA-256"`. `none` for Ed25519: + /// RFC 8032 fixes SHA-512 internally, so it is not a parameter. + algorithm-hash: func() -> option; + + /// The key's length in bits for algorithms parameterized by one — + /// the RSA modulus length (WebCrypto's + /// `RsaKeyAlgorithm.modulusLength`). `none` for Ed25519 and + /// ECDSA, whose key size is fixed by the algorithm or curve. + algorithm-length: func() -> option; + + /// The public exponent for RSA-family keys, as WebCrypto's + /// `RsaKeyAlgorithm.publicExponent` denominates it: the + /// exponent's big-endian bytes (`[1, 0, 1]` for 65537). `none` + /// for algorithms without one (Ed25519, ECDSA). + algorithm-public-exponent: func() -> option>; + + /// The public key material, in the minting interface's documented + /// public format. Algorithms without a raw public form (the RSA + /// family — the platform serves `spki` and `jwk` only) fail + /// `error.unsupported`. + /// + /// There is no extractability gate on this resource, so + /// `error.not-extractable` never occurs here. Export is still + /// fallible: a provider may hold the key as a handle it can *use* + /// but not *read* — verifying with it succeeds while recovering + /// its encoding fails with `error.other` (see `README.md`, + /// "Extractability"). + export-key-raw: async func() -> result, error>; + + /// The public key as an X.509 SubjectPublicKeyInfo (DER), with + /// the same fallibility as `export-key-raw`. + export-key-spki: async func() -> result, error>; + + /// The public key as a JWK (an RFC 8037 OKP public key for + /// Ed25519, an EC public key for ECDSA). See + /// `mac-key.export-key-jwk` for the package-wide JWK contract; + /// the same fallibility as `export-key-raw` applies. + export-key-jwk: async func() -> result; + } + + /// A private key. `sign` is one-shot on the immutable key, mirroring + /// `mac-key.sign`. The extractability contract in `README.md` applies. + resource signing-key { + /// Sign an entire byte stream. A success resolves only after the + /// stream is fully drained (its writer dropped). The minting + /// interface documents the signature's wire format. + /// + /// Fails only for operational reasons (`error.other`). + sign: async func(data: stream) -> result, error>; + + /// See `verifying-key.algorithm-name`. + algorithm-name: func() -> string; + + /// See `verifying-key.algorithm-curve`. + algorithm-curve: func() -> option; + + /// See `verifying-key.algorithm-hash`. + algorithm-hash: func() -> option; + + /// See `verifying-key.algorithm-length`. + algorithm-length: func() -> option; + + /// See `verifying-key.algorithm-public-exponent`. + algorithm-public-exponent: func() -> option>; + + /// Whether the export functions may return this key's material. + /// Mint-time recorded policy, which platform-backed key storage + /// also honors. + extractable: func() -> bool; + + /// Whether this key permits `sign`. A refused operation fails + /// `error.not-permitted`. + can-sign: func() -> bool; + + /// The private key as a JWK (an RFC 8037 OKP private key for + /// Ed25519, an EC private key for ECDSA). Fails + /// `error.not-extractable` unless the key was minted extractable; + /// fallible beyond the gate like every export (`README.md`, + /// "Extractability"). See `mac-key.export-key-jwk` for the + /// package-wide JWK contract. + export-key-jwk: async func() -> result; + + /// The private key as a PKCS#8 PrivateKeyInfo (DER), behind the + /// same extractability gate as `export-key-jwk`. + export-key-pkcs8: async func() -> result, error>; + + /// The private-key JWK serialization as a `wrap-input`, for + /// wrapping under another key (see the `wrapping` interface). + /// Behind the same extractability gate as `export-key-jwk`, and + /// fallible beyond it like every export; the material itself + /// never reaches the caller. + to-wrap-input-jwk: async func() -> result; + + /// The PKCS#8 serialization as a `wrap-input`, behind the same + /// gate. + to-wrap-input-pkcs8: async func() -> result; + } +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/wrapping.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/wrapping.wit new file mode 100644 index 00000000..6c3e1e04 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/wrapping.wit @@ -0,0 +1,178 @@ +package polymorph:webcrypto@0.1.0; + +/// Provider-held intermediates for key wrapping: moving one key's material +/// under another key without the material transiting the caller. +/// +/// Wrapping is two composable halves, mirroring how derivation trades in +/// `derive-input`: +/// +/// - **Wrap**: a key resource serializes its material into a +/// `wrap-input` (`to-wrap-input-raw` and its format siblings, +/// behind the key's extractability gate), and a wrapping-capable key +/// encrypts it into wrapped bytes (`aead.aead-key.wrap`, +/// `cipher.cipher-key.wrap`, `key-wrap.kw-key.wrap`). +/// - **Unwrap**: a wrapping-capable key decrypts wrapped bytes into an +/// `unwrap-input` (`aead.aead-key.unwrap` and siblings), and a minting +/// interface's unwrap function mints a typed resource from it — a key +/// (`aes-gcm.unwrap-key-raw`, `hmac-sha2.unwrap-key-jwk`, …) or a KDF +/// base secret (`hkdf.unwrap-ikm`, `pbkdf2.unwrap-password`). +/// +/// A `wrap-input` is the input to `wrap`; an `unwrap-input` is the input +/// to the unwrap mints — the *output* of `unwrap`, never its argument +/// (the wrapped bytes are). +/// +/// Security: +/// - The decrypted material never transits the consumer: unwrapping can +/// mint a *non-extractable* key from wrapped transport, which no +/// composition of `open` and `import-key-raw` can do. +/// - Neither resource exposes the material it holds, and neither is +/// reusable: the operation that takes one consumes it, on failure as +/// on success. Implementations SHOULD NOT retain the material longer +/// than the resource that denotes it. +/// +/// Resource types are per-instance, so an intermediate cannot cross +/// providers: both halves of a wrap or unwrap run inside one provider, +/// and only the wrapped bytes travel (see `README.md`, "Design notes"). +/// +/// Key material is small, so wrapping trades in `list` rather than +/// the byte streams the bulk operations use. +interface wrapping { + /// Serialized key material awaiting encryption under a wrapping key. + /// + /// Constructed by the key resources' `to-wrap-input-*` functions; + /// the serialization format chosen there travels with the resource, + /// and format-specific wrapping rules (such as `aes-kw`'s JWK + /// padding) key on it. Consumed by a wrapping key's `wrap`, on + /// failure as on success; construct one per wrap attempt. + resource wrap-input; + + /// Key material from a wrapped input, awaiting a typed mint. + /// + /// Produced by a wrapping key's `unwrap`; consumed by one unwrap + /// mint (on failure as on success — unwrap again to mint again), + /// which declares how the bytes are read (raw, JWK, PKCS#8) the way + /// the per-format imports do. The mint need not produce a key: the + /// KDF interfaces mint base secrets from it (`hkdf.unwrap-ikm`, + /// `pbkdf2.unwrap-password`), a strictly one-way door — no KDF + /// secret can produce a `wrap-input`. + /// + /// Security: + /// - An implementation decrypts — and, for the authenticated + /// wrapping kinds, verifies — at `unwrap`, or defers both to the + /// consuming mint, which then reports the unwrap operation's + /// error cases too. Either way, no mint succeeds on input whose + /// verification fails, and unverified material is never + /// observable. + /// - No grants ride this resource: the minted resource's usages and + /// extractability come from the mint's options alone (the W3C Web + /// Cryptography API's `unwrapKey` model). + resource unwrap-input; +} + +/// The `key-wrap` primitive kind: dedicated key-wrapping algorithms — +/// deterministic, integrity-checked encryption whose input domain is key +/// material, never messages (NIST SP 800-38F). +/// +/// This interface is algorithm-agnostic. Per-algorithm interfaces (such +/// as `aes-kw`) mint `kw-key` resources bound to their algorithm; the +/// wrap and unwrap operations hang off the key, as everywhere in this +/// package. +/// +/// Security: +/// - Wrapping is deterministic: no nonce is drawn, and the same key and +/// material yield the same wrapped bytes, so an observer can tell when +/// two wrapped payloads are equal. That is sound for high-entropy key +/// material and unsound for general data — which is why `wrap` accepts +/// only `wrap-input`: there is no direct bytes path. The restriction +/// is friction, not a guarantee: arbitrary bytes can still arrive +/// through an extractable import, as on the platform. +/// - The integrity check is the algorithm's (AES-KW's 64-bit ICV), with +/// a correspondingly weaker forgery bound than an AEAD tag. Prefer an +/// `aead` key for wrapping when no fixed format requires KW. +interface key-wrap { + use types.{error}; + use wrapping.{wrap-input, unwrap-input}; + + /// Mint-time policy for a `kw-key`. Grants nothing by default; see + /// `README.md`, "Key-options contract". The vocabulary is the W3C Web + /// Cryptography API's usage pair for AES-KW keys, which serve no + /// other operations. + resource kw-key-options { + constructor(); + /// Whether the minted key may `wrap`. Disabled by default. + can-wrap: func(allowed: bool); + /// Whether the minted key may `unwrap`. Disabled by default. + can-unwrap: func(allowed: bool); + /// Whether the minted key's material may be exported. Disabled by + /// default. + extractable: func(allowed: bool); + } + + /// A key-wrapping key: an unforgeable capability, bound to one + /// algorithm at creation. The extractability and getter contracts in + /// `README.md` apply. + resource kw-key { + /// Encrypt serialized key material. The minting interface + /// documents the wrapped wire format and the algorithm's input + /// domain; material whose serialization falls outside that domain + /// fails `error.invalid-key`. + /// + /// `input` is consumed. + /// + /// Requires `can-wrap`, else `error.not-permitted`. + wrap: async func(input: wrap-input) -> result, error>; + + /// Decrypt and integrity-check wrapped key material, as produced + /// by `wrap` under the same algorithm. The result awaits a typed + /// mint (see `unwrap-input`, including the verification-timing + /// latitude); the material never reaches the caller. + /// + /// Security: + /// - Any failure of the integrity check — including input that + /// cannot carry the algorithm's wrapped form (the minting + /// interface documents the domain) — reports + /// `error.authentication-failed` with no detail. + /// + /// Requires `can-unwrap`, else `error.not-permitted`. + unwrap: async func(wrapped: list) -> result; + + /// The registry name of the algorithm family this key is bound + /// to, e.g. `"AES-KW"` (WebCrypto's `KeyAlgorithm.name`). + /// Parameters are separate getters, as for + /// `mac-key.algorithm-name`. + algorithm-name: func() -> string; + + /// The key length in bits, e.g. `256` (WebCrypto's + /// `AesKeyAlgorithm.length`). + algorithm-length: func() -> u32; + + /// Whether the key material may be exported. + extractable: func() -> bool; + + /// Whether this key permits `wrap`. A refused operation fails + /// `error.not-permitted`. + can-wrap: func() -> bool; + + /// Whether this key permits `unwrap`. See `can-wrap`. + can-unwrap: func() -> bool; + + /// The raw key material. Fails with `error.not-extractable` + /// unless the key was created with `extractable` true. + export-key-raw: async func() -> result, error>; + + /// The key as an RFC 7517 JSON Web Key, behind the same + /// extractability gate as `export-key-raw`. See `README.md`, + /// "JWK contract". + export-key-jwk: async func() -> result; + + /// This key's raw material as a `wrap-input`, for wrapping + /// under another key. Behind the same extractability gate as + /// `export-key-raw`; the material itself never reaches the + /// caller. + to-wrap-input-raw: async func() -> result; + + /// The JWK serialization as a `wrap-input`, behind the same + /// gate. See `README.md`, "JWK contract". + to-wrap-input-jwk: async func() -> result; + } +} diff --git a/runtime/device-seal/wit/deps/polymorph-webcrypto/x25519.wit b/runtime/device-seal/wit/deps/polymorph-webcrypto/x25519.wit new file mode 100644 index 00000000..20db0198 --- /dev/null +++ b/runtime/device-seal/wit/deps/polymorph-webcrypto/x25519.wit @@ -0,0 +1,85 @@ +package polymorph:webcrypto@0.1.0; + +/// X25519 key agreement (RFC 7748), as the Web Cryptography API serves it. +/// +/// Keys returned here drive `key-agreement.secret-key.agree`, whose +/// `derive-input` output feeds `derive-bits`, the key-minting interfaces' +/// `derive-key`, and `hkdf-sha2.prepare-from` — the spec's own worked example +/// (X25519 → HKDF → AES-GCM) runs entirely through handles. +/// +/// Formats follow the format-admission rule (see `README.md`, "Design +/// notes"): every format is one a platform-backed host passes to the +/// platform verbatim. The public key travels as the raw 32-byte RFC 7748 +/// u-coordinate (the platform's public-only `raw` format); the secret key +/// travels only as an RFC 8037 OKP private JWK (the platform's `jwk` +/// format — its sole non-ASN.1 private form). Bare secret scalars have no +/// platform door and are not a format here. +interface x25519 { + use types.{error}; + use key-agreement.{public-key, secret-key, agreement-key-options}; + use wrapping.{unwrap-input}; + + /// Import a raw 32-byte RFC 7748 u-coordinate as a public key. + /// + /// Import is deliberately permissive, as the platform's is: any + /// 32-byte string is accepted (the high bit is masked at use, per RFC + /// 7748), and degenerate keys — small-order points, non-canonical + /// encodings, points on the twist — are not rejected here. A + /// small-order key surfaces at `agree` as `error.invalid-key` (the + /// all-zero check); material of any other length fails + /// `error.invalid-key` at import. + import-public-key-raw: async func(raw: list) -> result; + + /// Import a public key as an X.509 SubjectPublicKeyInfo (DER, RFC + /// 8410 algorithm id 1.3.101.110). The embedded u-coordinate is + /// admitted exactly as `import-public-key-raw` admits it. + import-public-key-spki: async func(spki: list) -> result; + + /// Import a public key as an RFC 8037 OKP public JWK (`kty: "OKP"`, + /// `crv: "X25519"`, `x`). `jwk` is the JWK as JSON text; see + /// `mac-key.export-key-jwk` for the package-wide JWK contract. + import-public-key-jwk: async func(jwk: string) -> result; + + /// Import a static secret key as an RFC 8037 OKP private JWK + /// (`kty: "OKP"`, `crv: "X25519"`, with `x` and `d` both required — + /// RFC 8037 makes the public coordinate mandatory, so this is + /// inherently the public+private form). + /// + /// `jwk` is the JWK as JSON text; the implementation owns the parse + /// (see `mac-key.export-key-jwk` for the package-wide JWK contract, + /// including `ext` validation against the options' extractability). + /// `d` is the 32-byte scalar, clamped at use per RFC 7748. + /// + /// Security: + /// - Implementations MAY reject a JWK whose `x` is not the public key + /// of `d` with `error.invalid-key`, and MUST NOT trust `x` for any + /// operation: the imported key's identity is `d`'s. (The W3C Web + /// Cryptography API's import steps do not mandate the consistency + /// check, and engines differ, so a platform-backed host cannot + /// promise it.) + import-secret-key-jwk: async func(jwk: string, options: agreement-key-options) -> result; + + /// Import a static secret key as a PKCS#8 PrivateKeyInfo (DER, RFC + /// 8410: the 32-byte scalar in a CurvePrivateKey). The scalar is + /// clamped at use per RFC 7748, like the JWK import's `d`. + import-secret-key-pkcs8: async func(pkcs8: list, options: agreement-key-options) -> result; + + /// Generate a fresh X25519 key pair. + generate-key: async func(options: agreement-key-options) -> result, error>; + + /// Mint a static secret key from unwrapped key material (see the + /// `wrapping` interface): `input`'s bytes are read as an OKP private + /// JWK, subject to `import-secret-key-jwk`'s contract plus the + /// unwrap-path `use`/`key_ops` checks (see `README.md`, "JWK + /// contract"). `input` is consumed. + /// + /// The minted key's grants and extractability come from `options` + /// alone (the W3C Web Cryptography API's `unwrapKey` model). + unwrap-secret-key-jwk: async func(input: unwrap-input, options: agreement-key-options) -> result; + + /// Mint a static secret key from unwrapped key material read as a + /// PKCS#8 PrivateKeyInfo, subject to `import-secret-key-pkcs8`'s + /// contract. `input` is consumed; see `unwrap-secret-key-jwk` for + /// the options model. + unwrap-secret-key-pkcs8: async func(input: unwrap-input, options: agreement-key-options) -> result; +} diff --git a/runtime/device-seal/wit/deps/wasi-random/random.wit b/runtime/device-seal/wit/deps/wasi-random/random.wit new file mode 100644 index 00000000..44379546 --- /dev/null +++ b/runtime/device-seal/wit/deps/wasi-random/random.wit @@ -0,0 +1,17 @@ +/// Vendored from wasi-random 0.2.0 (the `random` interface only): the +/// world imports it so the webcrypto-componentize library can serve +/// `crypto.getRandomValues` from the host's entropy. +package wasi:random@0.2.0; + +/// WASI Random is a random data API. +/// +/// It is intended to be portable at least between Unix-family platforms and +/// Windows. +interface random { + /// Return `len` cryptographically-secure random or pseudo-random bytes. + get-random-bytes: func(len: u64) -> list; + + /// Return a cryptographically-secure random or pseudo-random `u64` + /// value. + get-random-u64: func() -> u64; +} diff --git a/runtime/device-seal/wit/world.wit b/runtime/device-seal/wit/world.wit new file mode 100644 index 00000000..772bc02c --- /dev/null +++ b/runtime/device-seal/wit/world.wit @@ -0,0 +1,400 @@ +/// THE DEVICE SEAL AS A COMPONENT. +/// +/// This world is the per-device sealing layer of runtime/device-store — +/// `seal.ts` (the DEK and the KEK ladder), `sealed-fs.ts`'s per-file +/// sealing, and `identity-keys.ts` (the device's signing handles) — moved +/// out of TypeScript and behind a component boundary. The governing design +/// record is runtime/PERSISTENCE.md ("Sealing", "The PRF rung"); its +/// vocabulary is this file's vocabulary, and where this file and seal.ts +/// disagree on a rule, PERSISTENCE.md decides. +/// +/// WHAT THE BOUNDARY BUYS, stated plainly so it can be checked: +/// +/// - THE UNSEALED DEK EXISTS NOWHERE IN JAVASCRIPT. Today the worker holds +/// a `CryptoKey` in a variable and hands it to two modules; the grep in +/// demo/scripts/check-invariants.sh (d) is what stands between that +/// variable and `exportKey`. Here the DEK is a resource inside this +/// component. The worker holds a component and asks it to seal and open +/// bytes; it has no handle to export. +/// - THE COMPONENT'S REACH IS ITS IMPORTS. It can spell five record kinds +/// and four key slots of ONE device's namespace, and nothing else: not +/// another device's database, not the index, not `localStorage`. The +/// engine composite already has this property; the seal did not. +/// - THE LADDER'S RULES ARE NATIVELY TESTABLE. "absent origin means +/// generated", "a PRF rung is always reachable", "reseal keeps the PRF +/// wrap", the PMSEALv1 header — today these are asserted only by a +/// 49-row browser matrix. Here they are `cargo test`. +/// +/// WHAT IT DOES NOT CHANGE: the WebCrypto operations are the platform's, +/// reached through `polymorph:webcrypto`. PBKDF2 costs what it costs; +/// `extractable: false` is still the platform's own flag; a persisted +/// handle is still a structured-cloned `CryptoKey` in IndexedDB. The +/// component decides WHICH operations happen and WHO holds the results; +/// the platform still performs them. +/// +/// ON-DISK FORMAT: UNCHANGED, and this is a requirement, not a convenience. +/// Every record below mirrors a structured-clone shape seal.ts already +/// writes (field for field, absent-means-default rules preserved), and +/// the `namespace` import is TYPED PER RECORD so the host's codec is a +/// field mapping with no decisions in it. A device sealed by seal.ts +/// yesterday unseals through this component today; the matrix's reload +/// rows and a captured fixture gate it. +package polyvisor:device-seal@0.1.0; + +/// Shared vocabulary. Functionless — no host implementation. +interface types { + /// Why the sealing layer refused, as a closed set (seal.ts + /// `SealError.code`). The unseal ceremony branches on these. + enum seal-code { + /// The passphrase did not open the wrap. Indistinguishable by + /// construction from "wrong wrap record". + wrong-passphrase, + /// The passkey-derived KEK did not open the PRF wrap — a wrong + /// credential, a wrong PRF input, or a record copied in from + /// another device (the HKDF `info` binds the device id). + wrong-passkey, + /// The device has no rung of the kind asked for. + no-rung, + /// A second mint was refused: this device already has a DEK, and + /// a second one would silently orphan every byte under the first. + already-sealed, + /// Stored bytes that did not authenticate — GCM's tag, AES-KW's + /// integrity check, or a record that failed shape validation. + /// "Nothing stored" and "stored and altered" are different facts. + tampered, + /// A request the layer refuses on principle: an empty + /// passphrase, a KEK handle with the wrong shape, a platform + /// operation that declined. + unsupported, + } + + /// A refusal from the sealing layer (seal.ts `SealError`): the code a + /// caller branches on, and THE SENTENCE THE VISOR SHOWS. The sentence + /// is the component's — seal.ts had eleven distinct ones ("an empty + /// passphrase cannot seal a device", "this device has no platform + /// rung to re-key from", …) and the sheets render them, so the + /// component that knows which refusal it is states it, in framework + /// voice. Never a key, never user-typed text. + record seal-error { + code: seal-code, + message: string, + } + + /// WHETHER ANYBODY KNOWS A PASSPHRASE (seal.ts `PassphraseWrap.origin`). + /// `user` — a person chose it. `generated` — minted from random bytes + /// and dropped on the floor, which is how a T0 device is sealed with + /// no ceremony. The reseal-upgrade guard consults this bit before + /// deleting the platform wrap. + enum passphrase-origin { user, generated } + + /// Which rungs this device HAS — the picker's question, asked without + /// opening anything (seal.ts `SealState`). + record seal-state { + /// A passphrase rung EXISTS. Says nothing about reachability. + passphrase: bool, + /// A passphrase rung exists AND a person chose it. + user-passphrase: bool, + /// Auto-unseals from the platform key until reseal. + until-reseal: bool, + /// A passkey rung exists. Always reachable by the authenticator's + /// holder — a PRF rung has no `origin` because it cannot be a + /// door with no key. + prf: bool, + } + + /// What the PAGE hands the worker beside the KEK at enrollment, and + /// reads back to run an unseal assertion: the ceremony half of the + /// PRF wrap, minus the wrapped bytes (seal.ts `PrfEnrollment`). + record prf-enrollment { + credential-id: list, + transports: list, + rp-id: string, + /// The 32 bytes handed to the PRF extension as `eval.first`. + prf-input: list, + /// HKDF's salt, 32 bytes. + hkdf-salt: list, + } + + /// Which persisted signing identity (identity-keys.ts's two ids). + enum identity-slot { + /// `device-signing`: the device's keyhive signing identity. + device-signing, + /// `device-endpoint`: the iroh endpoint key pair. + device-endpoint, + } +} + +/// THE DEVICE'S NAMESPACE, as the component may see it: one device's +/// `seal`, `sealed` and `identity` stores, typed per record kind. +/// +/// The host implements this over `DeviceNamespace` (namespace.ts) for ONE +/// device. There is no `device-id` parameter anywhere in this interface: +/// which device is decided at instantiation, by which namespace the host +/// closed over, and the component cannot name another. +/// +/// RECORD SHAPES ARE seal.ts's, FIELD FOR FIELD. The host's job per +/// function is a structured-clone object ↔ record mapping and nothing +/// else — no defaults applied, no validation, no decisions. An absent +/// optional field crosses as `none`; the component owns the rule for what +/// `none` means (e.g. `origin: none` reads as `generated`). +/// +/// KEY HANDLES cross as `polymorph:webcrypto` resources, bridged by the +/// #391 persistence seam: the host calls `toCryptoKey()` on the resource it +/// receives and structured-clones the `CryptoKey` into IndexedDB +/// (non-extractability preserved); on read it calls `fromCryptoKey`. +/// +/// VALIDATE-ON-LOAD IS THE HOST'S, and `fromCryptoKey` is NOT all of it. +/// `fromCryptoKey` refuses the wrong type, algorithm and usages; it does +/// not look at `extractable`. The host's usability predicate for an +/// identity entry is identity-keys.ts `usableIdentity`, in full: both +/// halves are `CryptoKey`s of the right type and algorithm, the private +/// half has `extractable === false` and `sign`, the public half `verify`. +/// It lives on the host because `put-identity`'s add-if-absent +/// transaction has to apply it to the entry it finds — a transaction +/// cannot call back into the component — and a stored entry that fails +/// it is DISCARDED there and replaced by the caller's pair, with a +/// console warning (identity-keys.ts `loadOrMintIdentity`: "the +/// planted-junk case ends with a real key rather than a loop"). The +/// component still checks `extractable()` on every pair it receives and +/// refuses one that is extractable, so a host that gets the predicate +/// wrong fails loudly rather than adopting a readable key. +/// +/// A stored value that is not a usable key of the right kind reads as +/// `none`, and the host says so on the console. +interface namespace { + use types.{passphrase-origin, identity-slot}; + use polymorph:webcrypto/key-wrap@0.1.0.{kw-key}; + use polymorph:webcrypto/signature@0.1.0.{signing-key, verifying-key}; + + /// `seal` store, key `wrap:passphrase` (seal.ts `PassphraseWrap`). + record passphrase-wrap { + /// PBKDF2-SHA-256 iteration count, RECORDED so a later floor + /// does not orphan existing devices. + iterations: u32, + /// 16 bytes, fresh per wrap. + salt: list, + /// The DEK, AES-KW-wrapped under the derived KEK. + wrapped: list, + /// Absent in records seal.ts wrote before the bit existed. + origin: option, + } + + /// `seal` store, key `wrap:platform` (seal.ts `PlatformWrap`). The + /// wrapping key is the handle at `platform-kek`. + record platform-wrap { + wrapped: list, + } + + /// `seal` store, key `wrap:prf` (seal.ts `PrfWrap`). `kdf` is fixed + /// at `prf-hkdf-sha-256` by this version; the host refuses to map a + /// record with another tag (reads as `none`). + record prf-wrap { + credential-id: list, + transports: list, + rp-id: string, + prf-input: list, + hkdf-salt: list, + wrapped: list, + } + + /// `sealed` store, any key (seal.ts `SealedValue`): AES-GCM under the + /// DEK with the key string as additional data. + record sealed-value { + /// 12 bytes. + iv: list, + ct: list, + } + + get-passphrase-wrap: async func() -> option; + put-passphrase-wrap: async func(rec: passphrase-wrap); + get-platform-wrap: async func() -> option; + put-platform-wrap: async func(rec: platform-wrap); + delete-platform-wrap: async func(); + get-prf-wrap: async func() -> option; + put-prf-wrap: async func(rec: prf-wrap); + + /// `seal` store, key `kek:platform`: the non-extractable AES-KW + /// platform key, as a handle. `put` stores `toCryptoKey()`. + get-platform-kek: async func() -> option; + put-platform-kek: async func(key: borrow); + delete-platform-kek: async func(); + + get-sealed: async func(key: string) -> option; + put-sealed: async func(key: string, rec: sealed-value); + delete-sealed: async func(key: string); + + /// `identity` store, keyed by slot. RACE-FREE FIRST MINT + /// (identity-keys.ts): `put-identity` is ADD-IF-ABSENT and returns + /// what is stored afterwards — the caller's pair if it won, the + /// earlier winner's if it lost. Two workers racing to mint agree on + /// one identity. An entry found in the transaction that fails the + /// usability predicate above counts as ABSENT: it is deleted and the + /// caller's pair is stored. + get-identity: async func(slot: identity-slot) -> option>; + put-identity: async func(slot: identity-slot, signing: borrow, verifying: borrow) -> tuple; + delete-identity: async func(slot: identity-slot); +} + +/// THE KEK LADDER AND THE DEK — seal.ts's export surface, with one change +/// of shape: NO FUNCTION RETURNS A DEK. Where seal.ts handed back a +/// non-extractable `CryptoKey` for the worker to hold, this interface +/// parks it inside the component (`unsealed()` says whether one is +/// parked) and the `sealed` interface spends it. Dropping the component +/// re-seals the device, exactly as dropping the handle did. +/// +/// Each function's rules are seal.ts's, cited by name. Two are worth +/// restating because they are the ones a port gets wrong: +/// +/// - THE DEK IS BORN EXTRACTABLE AND PARKED NON-EXTRACTABLE. `wrapKey` +/// needs an extractable key, so the mint and every re-wrap unwrap the +/// DEK extractable for the length of the ceremony and then re-unwrap +/// it `extractable: false` for holding (seal.ts `wrappableDek`). The +/// parked handle is never the wrappable one. +/// - THE SINGLE WRITE LANDS AFTER EVERY FALLIBLE STEP. A ceremony that +/// fails part-way leaves the namespace exactly as it was. +interface seal { + use types.{seal-error, seal-state, passphrase-origin, prf-enrollment}; + use polymorph:webcrypto/key-wrap@0.1.0.{kw-key}; + + state: async func() -> seal-state; + /// Whether a DEK is parked in this component. + /// + /// ON polyengine HOSTS EVERY EXPORT IS PROMISE-SHAPED, this one + /// included (embedder-api.md, "Exports are uniformly Promise-shaped"); + /// a host reading it as a synchronous boolean gets a truthy Promise + /// and an always-unsealed device. A host with synchronous call sites + /// mirrors the parked bit itself from the five paths that change it + /// (the four `unseal-*`/`create-sealed-dek` successes and `forget`). + unsealed: func() -> bool; + /// Drop the parked DEK. The device is sealed as far as this + /// component is concerned; the namespace is untouched. + forget: func(); + + /// Mint the DEK and seal it under a passphrase — the `every-session` + /// rung (seal.ts `createSealedDek`). Refuses `already-sealed` rather + /// than replacing; refuses an empty passphrase as `unsupported`. + /// Parks the DEK on success. + create-sealed-dek: async func(passphrase: string, origin: passphrase-origin) -> result<_, seal-error>; + /// THE LOGIN (seal.ts `unsealWithPassphrase`). Parks the DEK. + unseal-with-passphrase: async func(passphrase: string) -> result<_, seal-error>; + /// Re-wrap under a new passphrase; the salt rotates, the DEK does not + /// (seal.ts `rekeyPassphrase`). Marks the rung `user`. + rekey-passphrase: async func(old: string, new: string) -> result<_, seal-error>; + /// Arm `until-reseal`: mint a non-extractable platform AES-KW key, + /// wrap the DEK under it, store both (seal.ts `enableUntilReseal`). + /// ADDITIVE — the passphrase rung stays. + enable-until-reseal: async func(passphrase: string) -> result<_, seal-error>; + /// Give a platform-rung device a passphrase it did not have (seal.ts + /// `rekeyFromPlatform`): authorised by the platform wrap. + rekey-from-platform: async func(new: string) -> result<_, seal-error>; + /// Open from the platform wrap. `ok(false)` when the device has no + /// platform rung — the normal case, not an error (seal.ts + /// `unsealFromPlatform` returning null). Parks the DEK on `ok(true)`. + unseal-from-platform: async func() -> result; + + /// The PRF rung's ceremony half, for the page's assertion (seal.ts + /// `getPrfEnrollment`). + get-prf-enrollment: async func() -> result, seal-error>; + /// Enrol a passkey rung (seal.ts `enablePrf`). `kek` is the KEK the + /// PAGE derived from the PRF output — the derivation stays on the + /// page by PERSISTENCE.md's ruling, since `navigator.credentials` is + /// window-only; it enters here as a non-extractable AES-KW handle. + /// Authorised by the platform wrap when one exists, else by + /// `passphrase`. Writes nothing until the wrap is in hand. Refuses a + /// `kek` that is extractable or lacks wrap+unwrap as `unsupported`. + enable-prf: async func(kek: kw-key, enrollment: prf-enrollment, passphrase: option) -> result<_, seal-error>; + /// Open with the page-derived KEK (seal.ts `unsealWithPrf`). Parks + /// the DEK. A malformed record is `tampered`; the right record and + /// the wrong key is `wrong-passkey`. + unseal-with-prf: async func(kek: kw-key) -> result<_, seal-error>; + + /// Delete the platform wrap and its key (seal.ts `reseal`). The PRF + /// wrap and the passphrase wrap SURVIVE — an assertion per unseal is + /// the PRF rung's whole point. + reseal: async func(); +} + +/// SPENDING THE PARKED DEK. Every function refuses with `no-rung` when +/// nothing is parked — the component is sealed. +interface sealed { + use types.{seal-error}; + + /// The sealed key/value surface (seal.ts `sealedPut`/`sealedGet`/ + /// `sealedDelete`): AES-GCM, fresh 12-byte IV per write, the key + /// string as additional data. A present value that does not open is + /// `tampered`, never `none`. + put: async func(key: string, bytes: list) -> result<_, seal-error>; + get: async func(key: string) -> result>, seal-error>; + delete: async func(key: string) -> result<_, seal-error>; + + /// Per-file sealing for the OPFS proxy (sealed-fs.ts): whole-file + /// AES-GCM. Layout: `PMSEALv1` magic (8 bytes) ‖ IV (12, fresh per + /// write) ‖ ciphertext ‖ tag. The additional data is THE MAGIC ONLY + /// (sealed-fs.ts:127-131) — not the header; the IV is authenticated + /// by GCM's own use of it. Byte-compatible with files sealed-fs.ts + /// has already written, which is the requirement. The host's + /// directory proxy calls these on every read and write and does + /// nothing else cryptographic. Sealing an EMPTY plaintext still costs + /// a full header and tag (sealed-fs.ts:139-147 has no empty case); + /// only a zero-length file — one created and never written — opens + /// as empty without a header (sealed-fs.ts's read side). + seal-file: async func(plaintext: list) -> result, seal-error>; + /// A file that is present but does not open — bad magic, short + /// header, GCM failure — is `tampered`. + open-file: async func(sealed: list) -> result, seal-error>; +} + +/// THE DEVICE'S SIGNING HANDLES (identity-keys.ts), posture `platform`: +/// non-extractable Ed25519, minted by the platform, persisted as handles +/// through the namespace's add-if-absent slot, never passphrase-derived. +/// +/// The worker receives the pair as `polymorph:webcrypto` resources and +/// builds the engine's `device-identity` fragment from them directly — +/// the SAME host module serves both components, which is what makes the +/// handoff a no-op (worker.ts's `fromCryptoKey` note). +interface identity { + use types.{seal-error, identity-slot}; + use polymorph:webcrypto/signature@0.1.0.{signing-key, verifying-key}; + + /// Load the slot's pair, or mint one and store it add-if-absent + /// (identity-keys.ts `loadOrMintIdentity`). What comes back is what + /// is STORED, which under a race may be another minter's pair. A + /// pair whose signing half reports `extractable()` is refused as + /// `unsupported` whichever door it came through — see `namespace`. + load-or-mint: async func(slot: identity-slot) -> result, seal-error>; + /// The slot's pair if one is stored and usable; `none` otherwise + /// (identity-keys.ts `loadIdentity`). + load: async func(slot: identity-slot) -> result>, seal-error>; + delete: async func(slot: identity-slot) -> result<_, seal-error>; +} + +world device-seal { + /// Salts, IVs, the generated passphrase: the platform's CSPRNG, never + /// an in-guest generator. `@polyengine/wasi` serves it. + import wasi:random/random@0.2.0; + + /// The platform's WebCrypto, by primitive. Only what the ladder + /// calls: PBKDF2 and HKDF (derive-input minting), AES-KW (the KEKs), + /// AES-GCM (the DEK), Ed25519 (identity). Unused interfaces are + /// dropped from the artifact by the linker; `wasm-tools component + /// wit` on the build is the authority for what is actually imported. + import polymorph:webcrypto/derivation@0.1.0; + import polymorph:webcrypto/pbkdf2@0.1.0; + import polymorph:webcrypto/pbkdf2-sha2@0.1.0; + import polymorph:webcrypto/hkdf@0.1.0; + import polymorph:webcrypto/hkdf-sha2@0.1.0; + import polymorph:webcrypto/wrapping@0.1.0; + import polymorph:webcrypto/key-wrap@0.1.0; + import polymorph:webcrypto/aes-kw@0.1.0; + import polymorph:webcrypto/aead@0.1.0; + import polymorph:webcrypto/aes-gcm@0.1.0; + import polymorph:webcrypto/signature@0.1.0; + import polymorph:webcrypto/ed25519-sign@0.1.0; + import polymorph:webcrypto/ed25519-verify@0.1.0; + + import namespace; + + export seal; + export sealed; + export identity; +} diff --git a/runtime/device-store/identity-keys.ts b/runtime/device-store/identity-keys.ts deleted file mode 100644 index b9545f62..00000000 --- a/runtime/device-store/identity-keys.ts +++ /dev/null @@ -1,234 +0,0 @@ -// THE DEVICE'S SIGNING IDENTITY, AT REST (PERSISTENCE.md, "Device -// signing identity"; posture `platform`). -// -// WHY THIS LIVES HERE AND NOT IN THE PORT. It used to be a WIT package -// in polymorph-webcrypto; webcrypto#391 ruled it out — "store a handle" -// is a browser-specific capability, not a WebCrypto one, so persistence -// is an EMBEDDER library and the guest-facing function is app-owned WIT. -// The engine's `device-identity`-shaped import is implemented by the -// embedder loading a handle from here and passing it to the port's -// `webcryptoHost().inject.signingKey` (webcrypto#390). This module -// therefore stops at the CryptoKey; it knows nothing about WIT, nothing -// about resources, and throws plain typed errors rather than -// `ComponentException`. It is honestly browser-only. -// -// WHAT IT BUYS. The Web Cryptography API gives `CryptoKey` structured- -// clone steps (§13), so a browser can put a key in IndexedDB and take -// it out again with `[[extractable]]` and the underlying handle intact. -// A NON-EXTRACTABLE key therefore survives a reload while its material -// stays unreadable — which is the whole point, and the reason the -// alternative (export material, re-import next time) is a downgrade -// rather than an equivalent: a wrapped or exported seed is offline- -// guessable at the wrapping secret's strength, while a non-extractable -// handle cannot leave the profile at all. -// -// VALIDATE ON LOAD. IndexedDB is writable by anything else running on -// this origin, so a stored entry is UNTRUSTED INPUT on the way back in. -// `usableIdentity` re-checks everything the mint promised — instance, -// type, algorithm, `extractable === false`, usages — and a failing -// entry is DISCARDED rather than merely rejected, so the caller's -// load-or-mint path is not an infinite loop against a planted value. -// The predicate's shape is wosh's `usable()` (site/identity-store.ts: -// 67-76), mirrored in spikes/worker-host/worker.ts:117-124. - -import { idbReq, withDb } from "./idb.ts"; -import type { DeviceNamespace } from "./namespace.ts"; -import { NS_STORES } from "./namespace.ts"; - -/** The object store inside the device's namespace database. */ -const STORE = "identity"; - -/** - * THE DEVICE'S OWN SIGNING IDENTITY, by name. - * - * One well-known id, because there is exactly one of these per device - * and three places need to agree on the spelling: the worker host (which - * loads it and hands it to the engine through the `device-identity` - * import), any tool that wants to inspect it, and the probe matrix that - * plants a rival pair to prove the engine refuses it. The store itself - * stays keyed — `persistIdentity(ns, id, pair)` — because nothing here - * should assume this is the only key a device will ever hold. - */ -export const DEVICE_IDENTITY_KEY = "device-signing"; - -/** - * THE DEVICE'S IROH ENDPOINT IDENTITY, by name — the transport key, - * kept beside the signing one and deliberately NOT the same key. - * - * In iroh the key is the address: the endpoint id peers dial is this - * pair's public half. Persisting it is what makes a device re-findable - * after a reload; before it existed the engine minted a fresh identity - * on every bind, so an id a peer had recorded went dead with the page. - * - * WHY A SECOND PAIR AND NOT A REUSE of the signing key (engine.wit's - * `device-identity.endpoint-key-pair`, where the ruling is written - * out): no cross-protocol key reuse between keyhive's signatures and - * iroh's handshake, and the transport identity — WHERE the device is — - * stays rotatable without repudiating the account identity that says - * WHO it is. Same store, same validate-on-load discipline, same - * algorithm; a different id, which is the whole difference. - */ -export const DEVICE_ENDPOINT_KEY = "device-endpoint"; - -/** - * Ed25519, and only Ed25519. The engine's device identity is an Ed25519 - * signing key; keeping the algorithm a CONSTANT rather than a stored - * field is what makes validate-on-load meaningful — the algorithm a - * loaded key is checked against is reconstructed here, never read back - * from a record an attacker could have rewritten. - */ -const ALGORITHM = "Ed25519" as const; - -/** A refusal from the identity store, typed so callers can tell "no key - * yet" (which is not an error and is reported as `null`) from a real - * storage failure. */ -export class IdentityKeyError extends Error { - constructor(readonly code: "extractable" | "algorithm" | "unavailable", message: string) { - super(message); - this.name = "IdentityKeyError"; - } -} - -/** - * Whether a stored value is a key pair this module is willing to hand - * back: exactly what `mint` makes, re-checked rather than assumed. - * - * Both halves are checked. The private half carries the promise - * (`extractable === false`, usable for `sign`); the public half is - * checked too because a swapped public key would make the caller - * publish an identity whose signatures it cannot produce — a confusing - * failure much later, instead of a discard now. - */ -export function usableIdentity(value: unknown): value is CryptoKeyPair { - const pair = value as CryptoKeyPair | null; - return ( - typeof pair === "object" && pair !== null && - pair.privateKey instanceof CryptoKey && - pair.publicKey instanceof CryptoKey && - pair.privateKey.type === "private" && - pair.publicKey.type === "public" && - pair.privateKey.algorithm.name === ALGORITHM && - pair.publicKey.algorithm.name === ALGORITHM && - pair.privateKey.extractable === false && - pair.privateKey.usages.includes("sign") && - pair.publicKey.usages.includes("verify") - ); -} - -/** Mint a fresh device identity. The PRIVATE half is non-extractable — - * this is the only place that decides it, and every load re-checks it. */ -function mint(): Promise { - return crypto.subtle.generateKey( - ALGORITHM, - /* extractable (private half) */ false, - ["sign", "verify"], - ) as Promise; -} - -/** - * Persist a key pair under `id` in this device's namespace, replacing - * whatever was there (idempotent under `id`). - * - * REFUSES AN EXTRACTABLE KEY. A stored key promises material that was - * never readable; accepting an extractable one would quietly turn the - * promise into a lie for every later loader — including `usableIdentity`, - * which would then be discarding a key the caller thought it had saved. - */ -export async function persistIdentity( - ns: DeviceNamespace, - id: string, - pair: CryptoKeyPair, -): Promise { - requireId(id); - if (pair.privateKey.extractable) { - throw new IdentityKeyError( - "extractable", - "an extractable signing key cannot be stored: a stored key promises material that was never readable", - ); - } - if (pair.privateKey.algorithm.name !== ALGORITHM) { - throw new IdentityKeyError( - "algorithm", - `this store holds ${ALGORITHM} signing keys; this key is ${pair.privateKey.algorithm.name}`, - ); - } - await ns.put(STORE, id, pair); -} - -/** - * The identity stored under `id`, or `null` when this device holds no - * usable one — including the case where it holds something that failed - * validation, which is DISCARDED on the way out (with a warning: a - * silent discard of a key the user's account depends on is exactly the - * event that should be visible in a console when someone is debugging - * "why am I a new device"). - */ -export async function loadIdentity( - ns: DeviceNamespace, - id: string, -): Promise { - requireId(id); - const stored = await ns.get(STORE, id); - if (stored === undefined) return null; - if (!usableIdentity(stored)) { - console.warn( - `device-store: the identity entry ${JSON.stringify(id)} in ${ns.dbName} is not a usable ` + - `non-extractable ${ALGORITHM} key pair; discarding it`, - ); - await ns.delete(STORE, id); - return null; - } - return stored; -} - -/** - * CREATE-OR-LOAD, RACE-FREE — the shape every caller actually wants. - * - * The race is not hypothetical: two tabs attaching to one device (or a - * restored session opening several at once) both want the identity to - * exist, and a read-then-write would mint two keys and let the later - * write silently replace the identity the earlier one had already begun - * signing with. That is an account-level bug — signatures under a key - * nothing can produce again. - * - * The settle is wosh's `loadOrMint` (site/identity-store.ts:79 f., - * mirrored in spikes/worker-host/worker.ts:126-158): mint a CANDIDATE - * first (key generation cannot happen inside a transaction — an `await` - * on anything but an IndexedDB request lets the transaction commit out - * from under you), then re-read INSIDE one readwrite transaction and let - * that transaction pick the winner. IndexedDB serialises overlapping - * readwrite transactions on a store, so exactly one caller sees an - * absent entry there. The loser's candidate is simply dropped: minting a - * key that is then discarded costs nothing but the entropy. - * - * A stored-but-invalid entry is discarded in the same transaction, so - * the planted-junk case ends with a real key rather than a loop. - */ -export async function loadOrMintIdentity( - ns: DeviceNamespace, - id: string, -): Promise<{ pair: CryptoKeyPair; minted: boolean }> { - requireId(id); - const existing = await loadIdentity(ns, id); - if (existing) return { pair: existing, minted: false }; - - const candidate = await mint(); - return await withDb(ns.dbName, NS_STORES, "readwrite", async (tx) => { - const store = tx.objectStore(STORE); - const raced = await idbReq(store.get(id) as IDBRequest); - if (usableIdentity(raced)) return { pair: raced, minted: false }; - store.put(candidate, id); - return { pair: candidate, minted: true }; - }); -} - -/** Forget one identity. (Forgetting the whole device is - * `removeDevice`, which takes the database with it.) */ -export function deleteIdentity(ns: DeviceNamespace, id: string): Promise { - requireId(id); - return ns.delete(STORE, id); -} - -function requireId(id: string): void { - if (id === "") throw new IdentityKeyError("unavailable", "an identity id must not be empty"); -} diff --git a/runtime/device-store/index.ts b/runtime/device-store/index.ts index 48901d00..560d966a 100644 --- a/runtime/device-store/index.ts +++ b/runtime/device-store/index.ts @@ -37,7 +37,8 @@ export type Posture = "seed" | "platform"; /** Which unseal ceremony the picker should offer. The tag lives in the * index precisely so the picker can decide WITHOUT opening anything. - * See seal.ts for what each rung actually buys. */ + * See the device seal component's wit/world.wit for what each rung + * actually buys. */ export type UnsealPolicy = "every-session" | "while-open" | "until-reseal" | "passkey"; /** One row of the index. Every field here rests in the clear. */ @@ -185,7 +186,8 @@ export async function touchDevice(id: string): Promise { * everything goes — so a refusal is a warning, never a failure of the * promotion. * - * Promotion does not carry the DEK: sealing the device is seal.ts's + * Promotion does not carry the DEK: sealing the device is the seal + * component's * job, and the caller runs it around this call. A device whose row says * `t1` and which has no wrap yet is a legal intermediate state — the * next boot's unseal ceremony sees "no rung" and can ask again. diff --git a/runtime/device-store/mod.ts b/runtime/device-store/mod.ts index 57cef92f..cb766611 100644 --- a/runtime/device-store/mod.ts +++ b/runtime/device-store/mod.ts @@ -46,34 +46,30 @@ export { export { deviceLockName, INDEX_DB, INDEX_STORE, nsDbName, nsDirName } from "./names.ts"; +// THE SEAL IS A COMPONENT (runtime/device-seal/). What the ladder used +// to export as loose functions is now `openSeal(ns, artifacts)`, whose +// `DeviceSeal` carries the ceremonies, the sealed KV/file surface and the +// device's signing handles. What stayed in TypeScript is the RECORD +// SHAPES and the one reader that needs no key. +// +// `openSeal` itself is exported from seal-component.ts and NOT re-exported +// here, for the reason worker.ts is not: it imports the polyengine +// embedder and the webcrypto port by bare specifier, and only the +// embedder can map those (runtime/README.md's resolution model). A +// consumer that reads the index to render a picker still needs no pins. export { - createSealedDek, - enableUntilReseal, - rekeyFromPlatform, - rekeyPassphrase, - reseal, + getPrfEnrollment, + type PassphraseWrap, + type PlatformWrap, + type PrfEnrollment, + type PrfWrap, SealError, - sealedDelete, - sealedGet, - sealedPut, + type SealedValue, type SealState, - sealState, - unsealFromPlatform, - unsealWithPassphrase, -} from "./seal.ts"; - -export { - deleteIdentity, - DEVICE_ENDPOINT_KEY, - DEVICE_IDENTITY_KEY, - IdentityKeyError, - loadIdentity, - loadOrMintIdentity, - persistIdentity, - usableIdentity, -} from "./identity-keys.ts"; +} from "./seal-records.ts"; export { + type FileSealer, type OpfsDirectoryHandle, type OpfsFileHandle, sealedDirectory, diff --git a/runtime/device-store/namespace.ts b/runtime/device-store/namespace.ts index 147f2de8..776104a4 100644 --- a/runtime/device-store/namespace.ts +++ b/runtime/device-store/namespace.ts @@ -18,13 +18,15 @@ import { INDEX_DB, INDEX_STORE, nsDbName, nsDirName } from "./names.ts"; /** The stores every namespace database has. * * - `seal` — the KEK ladder's persisted state: passphrase wrap, the - * non-extractable platform key handle and its wrap (seal.ts). + * non-extractable platform key handle and its wrap (the record + * shapes are seal-records.ts; the ladder is the seal component). * - `sealed` — the sealed key/value surface (`sealedPut`/`sealedGet`). * - `meta` — unsealed per-device bookkeeping the sweep needs: the lease. * NOTHING PERSONAL: this store rests in the clear exactly like the * index, so it carries timestamps and nothing else. * - `identity` — the device's signing identity as NON-EXTRACTABLE - * CryptoKey handles (identity-keys.ts). Unsealed by construction and + * CryptoKey handles (the seal component's `identity` interface, + * through seal-component.ts). Unsealed by construction and * deliberately so: the handles are unreadable because the platform * says so, not because a DEK hides them (PERSISTENCE.md, "Device * signing identity"). diff --git a/runtime/device-store/passkey.ts b/runtime/device-store/passkey.ts index 356c56d2..00d07011 100644 --- a/runtime/device-store/passkey.ts +++ b/runtime/device-store/passkey.ts @@ -7,7 +7,7 @@ // happen on the PAGE. This module runs them, derives the AES-KW key // encryption key from the assertion's PRF output, and hands the worker a // NON-EXTRACTABLE handle across the port — never the output, never the -// DEK. seal.ts validates that handle on arrival (`requirePrfKek`) rather +// DEK. The seal component validates that handle on arrival rather // than trusting where it came from. // // THE TRUST SENTENCE, said here because this is where it applies: the @@ -28,13 +28,13 @@ // THIS MODULE MUST NEVER BE IMPORTED BY worker.ts. Every symbol here // touches `window`/`navigator`, so an import would be a module that // cannot evaluate in the host's global — and the split above is the -// reason it is a separate file rather than a branch inside seal.ts. +// reason it is a separate file rather than a branch inside the seal. // // IT IMPORTS NO PACKAGE — DOM globals and sibling modules only, which is // runtime/README.md's resolution model for the device-store core, kept. import { openNamespace } from "./namespace.ts"; -import { getPrfEnrollment, type PrfEnrollment, SealError } from "./seal.ts"; +import { getPrfEnrollment, type PrfEnrollment, SealError } from "./seal-records.ts"; /** * Can this browser do the PRF extension at all — asked BEFORE offering @@ -254,7 +254,7 @@ export async function assertPasskey(deviceId: string): Promise { * that crosses to the worker, and both properties are what make that * crossing narrow: the receiver can ask the platform to unwrap with it * and can do nothing else, and it cannot be read back as bytes by - * either side. seal.ts re-checks both on arrival. + * either side. The seal component re-checks both on arrival. * * THE DEVICE ID IS BOUND INTO `info`, and that is the record's ruling * rather than decoration: a wrap record copied from one namespace into @@ -289,7 +289,7 @@ async function deriveKek( }, material, // `length` is REQUIRED for a derived AES key even though AES-KW-256 - // is implied by the usages — seal.ts's `kekFromPassphrase` records + // is implied by the usages — the ladder's own derivation records // the same Chromium refusal. { name: "AES-KW", length: 256 }, false, diff --git a/runtime/device-store/rpc.ts b/runtime/device-store/rpc.ts index d6084b07..82cd8506 100644 --- a/runtime/device-store/rpc.ts +++ b/runtime/device-store/rpc.ts @@ -28,7 +28,7 @@ import type { Driver, Tasks } from "../engine.ts"; import type { Posture, Tier, UnsealPolicy } from "./index.ts"; -import type { PrfEnrollment } from "./seal.ts"; +import type { PrfEnrollment } from "./seal-records.ts"; // --- how a rejection crosses ----------------------------------------------- // @@ -70,7 +70,7 @@ import type { PrfEnrollment } from "./seal.ts"; // where the form crosses: a `WireFailure` lives for one `postMessage` // between two realms of ONE page load, running one bundle of one engine // version. Nothing here reaches IndexedDB, OPFS or the checkpoint — -// sealed-fs.ts and seal.ts never see it. If a future change is tempted +// sealed-fs.ts and the seal never see it. If a future change is tempted // to log one, cache one, or put one in a checkpoint: that is the line. /** @@ -227,7 +227,7 @@ export function hostErrorOf(e: unknown, code?: string): HostError { // there) — it is not a value squeezed through a hole. // * BOTH ARE NON-EXTRACTABLE, so neither is a bearer secret in the way // a passphrase string is: the receiver can ask the platform to -// unwrap with it and can do nothing else with it, and seal.ts +// unwrap with it and can do nothing else with it, and the seal // validates that property on arrival rather than trusting it // (`requirePrfKek`). // * THE CEREMONY HAS NOWHERE ELSE TO RUN. `navigator.credentials` is @@ -403,7 +403,7 @@ export interface UnsealOptions { /** The `every-session` rung's input, and the first-seal ceremony's. * Never persisted, never logged, never echoed back in `status()`. */ passphrase?: string; - /** FIRST SEAL ONLY: also arm the `until-reseal` rung (seal.ts's + /** FIRST SEAL ONLY: also arm the `until-reseal` rung (the seal's * `enableUntilReseal`). Ignored on a device that already has rungs — * arming after the fact is a separate ceremony the UI owns. */ untilReseal?: boolean; @@ -415,7 +415,8 @@ export interface UnsealOptions { * It is one of the two CryptoKeys that cross this surface by design * (see the serialization-discipline note above); the ceremony cannot * run in the worker, because `navigator.credentials` is window-only. - * seal.ts validates the handle on arrival rather than trusting it. + * The seal component validates the handle on arrival rather than + * trusting it. * Never persisted by the worker, never logged, never echoed back in * `status()`. */ @@ -809,13 +810,13 @@ export interface DeviceStatus { /** True until an unseal succeeds, true again after `reseal()`. The * headline fact. */ sealed: boolean; - /** Which rungs this device HAS (seal.ts's `sealState`) — the picker's + /** Which rungs this device HAS (the seal's `state`) — the picker's * question, answerable without opening anything. `userPassphrase` is * the one a reseal ceremony branches on: a passphrase rung EXISTS on * every sealed device, but only a `user` one is a door anybody can * walk through. `prf` needs no such companion bit — a passkey rung * only exists because a person enrolled a credential they hold, so it - * is always walkable (seal.ts's `PrfWrap`). */ + * is always walkable (seal-records.ts's `PrfWrap`). */ rungs: { passphrase: boolean; userPassphrase: boolean; untilReseal: boolean; prf: boolean }; /** True when the next `unseal()` cannot succeed without one. * diff --git a/runtime/device-store/seal-component.ts b/runtime/device-store/seal-component.ts new file mode 100644 index 00000000..b17276ff --- /dev/null +++ b/runtime/device-store/seal-component.ts @@ -0,0 +1,711 @@ +// THE DEVICE SEAL, AS THE WORKER SEES IT: an adapter over the +// `polyvisor:device-seal@0.1.0` component (runtime/device-seal/). +// +// WHAT MOVED, AND WHAT THIS FILE IS. seal.ts's KEK ladder, sealed-fs.ts's +// per-file format and identity-keys.ts's signing handles are now Rust +// inside a component. This file instantiates it over ONE device's +// namespace and hands back typed wrappers. It performs no cryptography: +// every `crypto.subtle` call the three deleted modules made is now the +// component's, reached through `polymorph:webcrypto`. +// +// THE UNSEALED DEK EXISTS NOWHERE IN JAVASCRIPT (world.wit:13-18). No +// function below returns a key. Where the worker used to hold a +// `CryptoKey` in a variable and pass it to two modules, it now holds a +// `DeviceSeal` and asks it to seal and open bytes — there is no handle to +// export. `unsealed()` reports whether a DEK is parked; `forget()` drops +// it, exactly as dropping the old handle re-sealed the device. +// +// THE HOST'S JOB IS A CODEC WITH NO DECISIONS IN IT (world.wit:125-129). +// `namespaceImports` below maps structured-clone objects to WIT records +// field for field: `Uint8Array` ↔ `list`, an absent optional field ↔ +// `none`, an absent `transports` ↔ the empty list. It applies no +// defaults and validates nothing — "absent origin means generated" is +// the COMPONENT's rule, and duplicating it here would be a second place +// for it to drift. The one judgement the contract DOES assign to the +// host is validate-on-load for key handles, and it is discharged by +// `fromCryptoKey`'s own refusals (world.wit:131-137). +// +// MODULE IDENTITY. `@polymorph/webcrypto-polyengine` is spelled with the +// SAME bare specifier engine.ts:13 uses, and for the reason worker.ts +// spells out at length (worker.ts:80-92): the key wrappers minted here +// must land in the same class family the component's own webcrypto +// imports serve. A second copy of the package would mint wrappers the +// port does not recognize. + +import { + artifactsFromEnvelope, + type InstantiateSource, + instantiate, +} from "@polyengine/runtime/embedder"; +import { wasi } from "@polyengine/wasi"; +import { + KwKey, + SigningKey, + VerifyingKey, + webcryptoImports, +} from "@polymorph/webcrypto-polyengine"; +import { isComponentException } from "@polyengine/protocol"; +import { idbReq, withDb } from "./idb.ts"; +import { type DeviceNamespace, NS_STORES } from "./namespace.ts"; +import { + IDENTITY_STORE, + KEY_PASSPHRASE_WRAP, + KEY_PLATFORM_KEK, + KEY_PLATFORM_WRAP, + KEY_PRF_WRAP, + type PassphraseWrap, + type PlatformWrap, + type PrfEnrollment, + type PrfWrap, + SEAL_STORE, + SEALED_STORE, + SealError, + type SealedValue, + type SealState, +} from "./seal-records.ts"; + +// --- the interface ids ------------------------------------------------------ + +const I_TYPES = "polyvisor:device-seal/types@0.1.0"; +const I_NAMESPACE = "polyvisor:device-seal/namespace@0.1.0"; +const I_SEAL = "polyvisor:device-seal/seal@0.1.0"; +const I_SEALED = "polyvisor:device-seal/sealed@0.1.0"; +const I_IDENTITY = "polyvisor:device-seal/identity@0.1.0"; + +/** Which persisted signing identity — the WIT `identity-slot` enum, whose + * two case names ARE the IndexedDB keys identity-keys.ts used + * (`device-signing`, `device-endpoint`). The store stayed keyed by the + * same strings, so the slot needs no translation table. */ +export type IdentitySlot = "device-signing" | "device-endpoint"; + +/** A pair as the port's own wrappers, which is what the engine's + * `device-identity` fragment wants: the SAME host module serves this + * component and the engine, so the handoff is a no-op. */ +export type IdentityPair = [SigningKey, VerifyingKey]; + +/** + * The sealing surface the OPFS proxy needs, and nothing more — what + * `sealedDirectory` is given in place of the DEK it used to take. + */ +export interface FileSealer { + sealFile(plaintext: Uint8Array): Promise; + openFile(sealed: Uint8Array): Promise; +} + +/** One device's seal: the ladder, the sealed KV and file surface, and + * the device's signing handles. */ +export interface DeviceSeal { + /** Which rungs this device HAS, asked without opening anything. */ + state(): Promise; + /** + * Whether a DEK is parked — the successor to the worker's + * `dek !== null`, and SYNCHRONOUS as that test was. + * + * IT DOES NOT CALL THE COMPONENT, and the contract is what says it + * need not. `unsealed` is declared `func() -> bool`, but on polyengine + * hosts EVERY export is Promise-shaped — "a host reading it as a + * synchronous boolean gets a truthy Promise and an always-unsealed + * device", and "a host with synchronous call sites mirrors the parked + * bit itself from the five paths that change it" (world.wit:250-258). + * Several of this predicate's call sites ARE synchronous by + * construction (timer arming, `status()`'s sync half), so this is that + * host. + * + * The mirror is exact by construction rather than by hope: those five + * paths are the four ceremonies that succeed into a parked DEK + * (component.rs's `state::park` — create, the two unseals, and the + * platform open when it answers true) and `forget`. Every one is a + * wrapper below, and each updates the mirror as it returns. `state()` + * remains the component's answer to the DURABLE question; this is only + * "is one parked HERE, NOW". + */ + unsealed(): boolean; + /** + * THE SEAL GENERATION, for the races the worker's `dek` identity used + * to settle — incremented on every park and every forget. + * + * The WIT offers `unsealed()` (a bool) and blesses a host mirror of + * the parked bit (world.wit, `seal.unsealed`); this is that mirror + * carrying one more bit of history. The worker held a `CryptoKey` and + * compared it BY IDENTITY — `if (dek !== key) return` + * after an await, which is how a background cycle notices the device + * was resealed and re-unsealed underneath it (worker.ts's + * `syncMayRun`). A bool cannot express that: sealed→unsealed→sealed + * reads as `true` at both ends. So the adapter counts parks and + * forgets and hands out the count. It is bookkeeping ABOUT the + * component, not a widening of it — no key, no capability, and the + * component is not consulted. + */ + epoch(): number; + + /** Mint the DEK and seal it under a passphrase (the `every-session` + * rung). Refuses `already-sealed` rather than replacing. */ + createSealedDek(passphrase: string, origin?: "user" | "generated"): Promise; + /** THE LOGIN. Parks the DEK. */ + unsealWithPassphrase(passphrase: string): Promise; + /** Re-wrap under a new passphrase; the salt rotates, the DEK does not. */ + rekeyPassphrase(oldPassphrase: string, newPassphrase: string): Promise; + /** Arm `until-reseal`. ADDITIVE — the passphrase rung stays. */ + enableUntilReseal(passphrase: string): Promise; + /** Give a platform-rung device a passphrase it did not have. */ + rekeyFromPlatform(newPassphrase: string): Promise; + /** Open from the platform wrap. `false` when there is no platform rung + * — the normal case, not an error. */ + unsealFromPlatform(): Promise; + /** The PRF rung's ceremony half, for the page's assertion. */ + getPrfEnrollment(): Promise; + /** Enrol a passkey rung under the page-derived KEK. */ + enablePrf(kek: CryptoKey, enrollment: PrfEnrollment, passphrase?: string): Promise; + /** Open with the page-derived KEK. Parks the DEK. */ + unsealWithPrf(kek: CryptoKey): Promise; + /** Delete the platform wrap and its key. The passphrase and PRF wraps + * SURVIVE. */ + reseal(): Promise; + /** Drop the parked DEK. The namespace is untouched. Awaited because it + * IS a component call, whatever the WIT's `func` suggests. */ + forget(): Promise; + + /** Spending the parked DEK: the sealed KV surface and the per-file + * sealing the OPFS proxy calls. */ + readonly sealed: FileSealer & { + put(key: string, bytes: Uint8Array): Promise; + get(key: string): Promise; + delete(key: string): Promise; + }; + + /** The device's signing handles, as the port's own wrappers. */ + readonly identity: { + loadOrMint(slot: IdentitySlot): Promise; + load(slot: IdentitySlot): Promise; + delete(slot: IdentitySlot): Promise; + }; +} + +// --- refusals --------------------------------------------------------------- + +/** + * The WIT `seal-error` RECORD, as the value conventions shape it: a + * plain object with camelCase fields, its `code` the `seal-code` enum + * lifted to its kebab-case case name. + */ +interface SealErrorPayload { + code: SealError["code"]; + message: string; +} + +/** + * Lower the component's refusal onto the `SealError` callers branch on. + * + * THE SENTENCE IS THE COMPONENT'S, CARRIED VERBATIM. It used to be + * synthesised here — one generic line per code — because the variant had + * no room for text, and that was a real defect rather than a cosmetic + * one: the visor RENDERS `SealError.message` on its unseal and promotion + * sheets, so every refusal reached the user as this file's paraphrase + * instead of seal.ts's own words. A demo e2e scenario caught it + * (demo/e2e/scenarios/solo-persistence.ts:275) where the browser matrix + * could not, because the matrix asserts `code` and never reads the + * prose. `types.seal-error` now carries `message`, the component states + * seal.ts's exact sentence per site, and this function's whole job is to + * not get in the way of it. + * + * The two closed sets are the same set by construction — `seal-code` is + * `SealError.code`, case for case — so there is no mapping table here + * and there should never be one again. + */ +function sealErrorOf(e: unknown): unknown { + if (!isComponentException(e)) return e; + const payload = (e as { payload?: unknown }).payload as SealErrorPayload | undefined; + // The shape check is the discriminator, not validation: it says "this + // exception is a `seal-error`" and lets anything else through + // untouched, to be reported as whatever it actually is. + if ( + !payload || typeof payload.code !== "string" || typeof payload.message !== "string" + ) return e; + return new SealError(payload.code, payload.message); +} + +/** Run a component call, lowering its refusal. Every wrapper below goes + * through here so a `seal-error` never escapes as a raw + * `ComponentException`. */ +async function lowered(body: () => Promise): Promise { + try { + return await body(); + } catch (e) { + throw sealErrorOf(e); + } +} + +// --- the namespace import: a codec, and only a codec ------------------------ + +/** Warn ONCE per namespace per slot about a stored handle the port + * refuses. A silent discard of a key the user's account depends on is + * exactly the event that should be visible in a console when someone is + * debugging "why am I a new device" (identity-keys.ts's rule, kept). */ +function warnOnce(seen: Set, what: string, ns: DeviceNamespace, e: unknown): void { + if (seen.has(what)) return; + seen.add(what); + console.warn( + `device-store: the ${what} entry in ${ns.dbName} is not a usable key handle; ` + + `reading it as absent (${(e as Error)?.message ?? e})`, + ); +} + +function namespaceImports(ns: DeviceNamespace): Record { + const warned = new Set(); + + return { + // `passphrase-wrap`. `origin` is `option`: an + // absent field crosses as `none` and the COMPONENT reads that as + // `generated`. Nothing is defaulted here. + getPassphraseWrap: async () => { + const rec = await ns.get(SEAL_STORE, KEY_PASSPHRASE_WRAP); + if (!rec) return undefined; + const out: { + iterations: number; + salt: Uint8Array; + wrapped: Uint8Array; + origin?: "user" | "generated"; + } = { iterations: rec.iterations, salt: rec.salt, wrapped: rec.wrapped }; + if (rec.origin !== undefined) out.origin = rec.origin; + return out; + }, + // THE WRITE SIDE KEEPS THE ON-DISK SHAPE EXACTLY: `v` and `kdf` are + // seal.ts's constants and are re-attached here because the WIT record + // does not carry them — they are format tags, not ladder inputs, and + // dropping them would change the bytes a pre-port reader sees. + putPassphraseWrap: async (rec: { + iterations: number; + salt: Uint8Array; + wrapped: Uint8Array; + origin?: "user" | "generated"; + }) => { + const stored: PassphraseWrap = { + v: 1, + kdf: "PBKDF2-SHA-256", + iterations: rec.iterations, + salt: rec.salt, + wrapped: rec.wrapped, + }; + if (rec.origin !== undefined) stored.origin = rec.origin; + await ns.put(SEAL_STORE, KEY_PASSPHRASE_WRAP, stored); + }, + + getPlatformWrap: async () => { + const rec = await ns.get(SEAL_STORE, KEY_PLATFORM_WRAP); + return rec ? { wrapped: rec.wrapped } : undefined; + }, + putPlatformWrap: async (rec: { wrapped: Uint8Array }) => { + await ns.put(SEAL_STORE, KEY_PLATFORM_WRAP, { v: 1, wrapped: rec.wrapped } satisfies PlatformWrap); + }, + deletePlatformWrap: () => ns.delete(SEAL_STORE, KEY_PLATFORM_WRAP), + + /** + * `wrap:prf`. THE `kdf` TAG IS THE HOST'S ONE FILTER: the WIT fixes + * this version's construction at `prf-hkdf-sha-256` and rules that a + * record carrying another tag reads as `none` (world.wit:162-164). + * That is not validation of the ladder's rules — the component still + * owns those, and refuses a malformed record as `tampered`; it is the + * codec declining to MAP a record it has no shape for. + * + * `transports` is `list`, not an option: absent crosses as + * the empty list. + */ + getPrfWrap: async () => { + const rec = await ns.get(SEAL_STORE, KEY_PRF_WRAP); + if (!rec) return undefined; + if (rec.kdf !== "prf-hkdf-sha-256") return undefined; + return { + credentialId: rec.credentialId, + transports: rec.transports ?? [], + rpId: rec.rpId, + prfInput: rec.prfInput, + hkdfSalt: rec.hkdfSalt, + wrapped: rec.wrapped, + }; + }, + putPrfWrap: async (rec: { + credentialId: Uint8Array; + transports: string[]; + rpId: string; + prfInput: Uint8Array; + hkdfSalt: Uint8Array; + wrapped: Uint8Array; + }) => { + const stored: PrfWrap = { + v: 1, + kdf: "prf-hkdf-sha-256", + credentialId: rec.credentialId, + rpId: rec.rpId, + prfInput: rec.prfInput, + hkdfSalt: rec.hkdfSalt, + wrapped: rec.wrapped, + }; + // WRITE `transports` ONLY WHEN NON-EMPTY. seal.ts wrote the field + // only for a non-empty array (`if (enrollment.transports?.length)`), + // so writing `[]` would put a field on disk that no pre-port record + // carries — a shape change, in a format whose unchangedness is the + // requirement. + if (rec.transports.length > 0) stored.transports = rec.transports; + await ns.put(SEAL_STORE, KEY_PRF_WRAP, stored); + }, + + /** + * `kek:platform`, the non-extractable AES-KW platform key, as a + * handle. VALIDATE-ON-LOAD IS `fromCryptoKey`'s REFUSAL + * (world.wit:131-137): IndexedDB is writable by anything else on this + * origin, so a stored key is untrusted input on the way back in, and + * a value that is not a usable AES-KW handle reads as `none` rather + * than crossing. The component makes the second half of the judgement + * — it re-checks `extractable`/`can_unwrap` and refuses `tampered`. + */ + getPlatformKek: async () => { + const stored = await ns.get(SEAL_STORE, KEY_PLATFORM_KEK); + if (stored === undefined) return undefined; + try { + return KwKey.fromCryptoKey(stored as CryptoKey); + } catch (e) { + warnOnce(warned, "kek:platform", ns, e); + return undefined; + } + }, + putPlatformKek: async (key: KwKey) => { + await ns.put(SEAL_STORE, KEY_PLATFORM_KEK, key.toCryptoKey()); + }, + deletePlatformKek: () => ns.delete(SEAL_STORE, KEY_PLATFORM_KEK), + + getSealed: async (key: string) => { + const rec = await ns.get(SEALED_STORE, key); + return rec ? { iv: rec.iv, ct: rec.ct } : undefined; + }, + putSealed: async (key: string, rec: { iv: Uint8Array; ct: Uint8Array }) => { + await ns.put(SEALED_STORE, key, { v: 1, iv: rec.iv, ct: rec.ct } satisfies SealedValue); + }, + deleteSealed: (key: string) => ns.delete(SEALED_STORE, key), + + /** + * The `identity` store, keyed by slot. A stored pair that + * `fromCryptoKey` refuses reads as `none` — which IS + * identity-keys.ts's `usableIdentity` check, relocated to the seam + * the contract assigns it to. + */ + getIdentity: async (slot: IdentitySlot) => { + const pair = await readIdentity(ns, slot, warned); + return pair ?? undefined; + }, + + /** + * ADD-IF-ABSENT, AND IT RETURNS WHAT IS STORED (world.wit:200-206). + * + * The transaction discipline is identity-keys.ts's `loadOrMintIdentity` + * verbatim (identity-keys.ts:207-223) and the reason is unchanged: two + * workers attaching to one device both want the identity to exist, and + * a read-then-write would mint two keys and let the later write + * silently replace the identity the earlier one had already begun + * signing with. IndexedDB serialises overlapping readwrite + * transactions on a store, so exactly one caller sees an absent entry + * inside one. The loser's candidate is dropped and both callers agree + * on one identity — which is why this returns the STORED pair rather + * than the caller's. + * + * Key generation cannot happen inside a transaction (an `await` on + * anything but an IndexedDB request lets it commit out from under + * you), and does not need to: the component minted the candidate + * before calling. + */ + putIdentity: async ( + slot: IdentitySlot, + signing: SigningKey, + verifying: VerifyingKey, + ): Promise => { + const candidate: CryptoKeyPair = { + privateKey: signing.toCryptoKey(), + publicKey: verifying.toCryptoKey(), + }; + const stored = await withDb(ns.dbName, NS_STORES, "readwrite", async (tx) => { + const store = tx.objectStore(IDENTITY_STORE); + const raced = await idbReq(store.get(slot) as IDBRequest); + const usable = usablePair(raced); + if (usable) return usable; + // A stored-but-unusable entry is REPLACED in the same + // transaction, so the planted-junk case ends with a real key + // rather than a loop against the plant. + store.put(candidate, slot); + return candidate; + }); + return [ + SigningKey.fromCryptoKey(stored.privateKey), + VerifyingKey.fromCryptoKey(stored.publicKey), + ]; + }, + + deleteIdentity: (slot: IdentitySlot) => ns.delete(IDENTITY_STORE, slot), + }; +} + +/** + * VALIDATE-ON-LOAD for a stored identity pair — `usableIdentity`, in + * full, and THE HOST OWNS IT (world.wit:136-153). + * + * `fromCryptoKey` is not all of the predicate and the contract says so: + * it refuses the wrong type, algorithm and usages, but never looks at + * `extractable`. So this checks that bit itself and lets `fromCryptoKey` + * supply the rest. The bit is the one that matters — a PLANTED + * EXTRACTABLE PAIR is an attacker's handle wearing a stored key's + * costume, and adopting it would make the device sign under material + * that can be read back (identity-keys.ts:96-101's reasoning, kept). + * + * IT LIVES HERE RATHER THAN IN THE COMPONENT because `put-identity`'s + * add-if-absent transaction has to apply it to the entry it FINDS, and + * an IndexedDB transaction cannot call back into a component without + * committing out from under itself. The component re-checks the + * extractability bit on every pair it receives from either door and + * refuses `unsupported` (identity.rs `refuse_extractable`), so a codec + * bug here fails loudly at the seam instead of quietly downstream. + */ +function usablePair(value: unknown): CryptoKeyPair | undefined { + const pair = value as CryptoKeyPair | null; + if ( + typeof pair !== "object" || pair === null || + !(pair.privateKey instanceof CryptoKey) || !(pair.publicKey instanceof CryptoKey) || + pair.privateKey.extractable !== false + ) return undefined; + try { + // The port's own refusals are the rest of the validation; minting the + // wrappers is how they are asked for. + SigningKey.fromCryptoKey(pair.privateKey); + VerifyingKey.fromCryptoKey(pair.publicKey); + } catch { + return undefined; + } + return pair; +} + +async function readIdentity( + ns: DeviceNamespace, + slot: IdentitySlot, + warned: Set, +): Promise { + const stored = await ns.get(IDENTITY_STORE, slot); + if (stored === undefined) return undefined; + // ONE PREDICATE FOR BOTH READERS — `usablePair` carries the + // extractability finding this seam turns on. + const pair = usablePair(stored); + if (!pair) { + warnOnce( + warned, + `identity/${slot}`, + ns, + new Error("not a usable non-extractable Ed25519 key pair"), + ); + return undefined; + } + return [ + SigningKey.fromCryptoKey(pair.privateKey), + VerifyingKey.fromCryptoKey(pair.publicKey), + ]; +} + +// --- opening one ------------------------------------------------------------- + +/** The component's exports, as the conventions shape them. */ +interface SealExports { + state(): Promise; + forget(): Promise; + createSealedDek(passphrase: string, origin: "user" | "generated"): Promise; + unsealWithPassphrase(passphrase: string): Promise; + rekeyPassphrase(oldPassphrase: string, newPassphrase: string): Promise; + enableUntilReseal(passphrase: string): Promise; + rekeyFromPlatform(newPassphrase: string): Promise; + unsealFromPlatform(): Promise; + getPrfEnrollment(): Promise; + enablePrf(kek: KwKey, enrollment: WitEnrollment, passphrase: string | undefined): Promise; + unsealWithPrf(kek: KwKey): Promise; + reseal(): Promise; +} + +interface WitEnrollment { + credentialId: Uint8Array; + transports: string[]; + rpId: string; + prfInput: Uint8Array; + hkdfSalt: Uint8Array; +} + +interface SealedExports { + put(key: string, bytes: Uint8Array): Promise; + get(key: string): Promise; + delete(key: string): Promise; + sealFile(plaintext: Uint8Array): Promise; + openFile(sealed: Uint8Array): Promise; +} + +interface IdentityExports { + loadOrMint(slot: IdentitySlot): Promise; + load(slot: IdentitySlot): Promise; + delete(slot: IdentitySlot): Promise; +} + +/** + * The seal component's artifacts, fetched beside the engine's. + * + * `artifactsFromEnvelope` verifies the envelope's embedded sha-256 + * against the bytes, so a mismatched pair fails loudly at instantiation + * rather than subtly later. + */ +export function sealArtifacts(envelope: string, bytes: Uint8Array): InstantiateSource { + return artifactsFromEnvelope(envelope, bytes); +} + +/** + * INSTANTIATE ONE DEVICE'S SEAL. + * + * WHICH DEVICE IS DECIDED HERE AND NOWHERE ELSE. There is no `device-id` + * parameter anywhere in the `namespace` interface: the component can + * spell five record kinds and four key slots of the namespace this + * function closed over, and cannot name another (world.wit:117-123). + * + * The import record is `newEngine`'s shape (engine.ts:640-700): the WASI + * batteries, the whole webcrypto fragment, and ours. `webcryptoImports()` + * serves more interfaces than this component imports — the linker + * stripped the rest — and a superfluous import key is ignored, which is + * the same reason engine.ts can leave its wasi:http fragment in place. + * + * `types` is functionless and needs no implementation, but it IS in the + * artifact's import list (`wasm-tools component wit` on the build), so it + * gets the empty record engine.ts gives `store-fetch-types` for exactly + * this reason. + */ +export async function openSeal( + ns: DeviceNamespace, + source: InstantiateSource, +): Promise { + // PLAIN MODE, PINNED — `jspi: false` — and the reason is a measured crash. + // + // This component never needs a suspended wasm frame: every import it + // calls is an `async func` (the webcrypto surface, our `namespace`), so + // each is lowered through the component-model async ABI and the guest + // parks on a callback, never on a blocked frame. polyengine's + // auto-detection nevertheless picks jspi mode for this plan, because + // wit-bindgen emits the sync-form `subtask.cancel`/`task.cancel` + // built-ins for the drop-a-pending-future path, and those are classified + // block-capable (embedder-api.md, amendment A1; jspi/bridge.ts + // `trampolineNeedsSuspension`). In jspi mode every export is wrapped in + // `WebAssembly.promising`, and under Gecko — where JSPI is still + // pref-gated and experimental — the first async export call then kills + // the content process (firefox-smoke, and a minimal probe: the same + // sequence passes with `jspi: false` and crashes with `true`; the + // engine survives only because its plan genuinely needs suspension and + // its exports are driven differently). Forcing plain costs nothing here + // and would surface loudly if it were ever wrong: a sync-lowered import + // that returned a Promise is refused at the call site (`NeedsJspi`), + // never silently degraded. The recorded Gecko hazard this joins is + // PERSISTENCE.md's 0.5.1 addendum on `WebAssembly.promising` exports. + const instance = await instantiate(source, { + ...wasi({ cli: { args: [`device-seal-${ns.id.slice(0, 8)}`] } }), + ...webcryptoImports(), + [I_TYPES]: {}, + [I_NAMESPACE]: namespaceImports(ns), + }, { jspi: false }); + + const seal = instance.exports[I_SEAL] as SealExports; + const sealed = instance.exports[I_SEALED] as SealedExports; + const identity = instance.exports[I_IDENTITY] as IdentityExports; + if (!seal || typeof seal.forget !== "function") { + throw new Error( + `device-seal: export "${I_SEAL}" missing or shapeless; exports: ${ + Object.keys(instance.exports).join(", ") + }`, + ); + } + + // THE MIRROR AND THE GENERATION COUNTER — see `unsealed()` and + // `epoch()` on the interface. Both move in exactly one place per + // transition: `parked()` for a ceremony that ends with a DEK parked, + // and `forget()` below. + let parkedDek = false; + let epoch = 0; + const parked = async (body: () => Promise): Promise => { + await lowered(body); + parkedDek = true; + epoch++; + }; + + return { + state: () => lowered(() => seal.state()), + unsealed: () => parkedDek, + epoch: () => epoch, + + createSealedDek: (passphrase, origin = "user") => + parked(() => seal.createSealedDek(passphrase, origin)), + unsealWithPassphrase: (passphrase) => parked(() => seal.unsealWithPassphrase(passphrase)), + rekeyPassphrase: (oldPassphrase, newPassphrase) => + lowered(() => seal.rekeyPassphrase(oldPassphrase, newPassphrase)), + enableUntilReseal: (passphrase) => lowered(() => seal.enableUntilReseal(passphrase)), + rekeyFromPlatform: (newPassphrase) => lowered(() => seal.rekeyFromPlatform(newPassphrase)), + // `ok(false)` is "this device has no platform rung", which is the + // normal case and parks nothing — so the mirror moves only on true. + unsealFromPlatform: async () => { + const opened = await lowered(() => seal.unsealFromPlatform()); + if (opened) { + parkedDek = true; + epoch++; + } + return opened; + }, + + getPrfEnrollment: async () => { + const rec = await lowered(() => seal.getPrfEnrollment()); + if (!rec) return undefined; + const out: PrfEnrollment = { + credentialId: rec.credentialId, + rpId: rec.rpId, + prfInput: rec.prfInput, + hkdfSalt: rec.hkdfSalt, + }; + if (rec.transports.length > 0) out.transports = rec.transports; + return out; + }, + // THE KEK ARRIVES AS A `CryptoKey` — structured-cloned over the port + // from the page, which derived it from the PRF output (rpc.ts). It + // enters the component as a `kw-key` handle, and `fromCryptoKey`'s + // refusals are the first gate: a key that is not a usable AES-KW + // handle never reaches the ceremony. The component makes the second + // judgement (extractable, wrap/unwrap) and refuses `unsupported`. + enablePrf: (kek, enrollment, passphrase) => + lowered(() => + seal.enablePrf( + KwKey.fromCryptoKey(kek), + { + credentialId: enrollment.credentialId, + transports: enrollment.transports ?? [], + rpId: enrollment.rpId, + prfInput: enrollment.prfInput, + hkdfSalt: enrollment.hkdfSalt, + }, + passphrase, + ) + ), + unsealWithPrf: (kek) => parked(() => seal.unsealWithPrf(KwKey.fromCryptoKey(kek))), + + reseal: () => lowered(() => seal.reseal()), + forget: async () => { + await lowered(() => seal.forget()); + parkedDek = false; + epoch++; + }, + + sealed: { + put: (key, bytes) => lowered(() => sealed.put(key, bytes)), + get: (key) => lowered(() => sealed.get(key)), + delete: (key) => lowered(() => sealed.delete(key)), + sealFile: (plaintext) => lowered(() => sealed.sealFile(plaintext)), + openFile: (bytes) => lowered(() => sealed.openFile(bytes)), + }, + + identity: { + loadOrMint: (slot) => lowered(() => identity.loadOrMint(slot)), + load: (slot) => lowered(() => identity.load(slot)), + delete: (slot) => lowered(() => identity.delete(slot)), + }, + }; +} diff --git a/runtime/device-store/seal-records.ts b/runtime/device-store/seal-records.ts new file mode 100644 index 00000000..992017e8 --- /dev/null +++ b/runtime/device-store/seal-records.ts @@ -0,0 +1,271 @@ +// THE SEAL'S RECORD SHAPES, AND NOTHING THAT PERFORMS CRYPTOGRAPHY. +// +// The KEK ladder, the DEK and the per-file format moved into the device +// seal COMPONENT (runtime/device-seal/, `polyvisor:device-seal@0.1.0`); +// seal-component.ts is the adapter that instantiates it. What stayed +// behind is this file: the structured-clone shapes those records take in +// IndexedDB, the keys they rest under, the typed refusal callers branch +// on, and the ONE reader that needs no key. +// +// WHY THE SHAPES ARE STILL SPELLED HERE. The component owns the ladder's +// rules; the HOST owns the codec (world.wit's `namespace`: "the host's +// job per function is a structured-clone object ↔ record mapping and +// nothing else"). A codec needs the shape it maps, and two consumers +// outside the adapter — passkey.ts and rpc.ts — need the enrollment type +// without instantiating anything. Keeping the declarations here rather +// than inside the adapter is what lets the page read a PRF enrollment +// with no wasm in the graph. +// +// ON-DISK FORMAT: UNCHANGED, and this is a requirement, not a +// convenience (world.wit:35-41). Every doc comment below is the one +// seal.ts carried, kept verbatim, because the rules they state are the +// rules the component now enforces and the codec must not quietly +// reinterpret. A device sealed before the port opens after it; the +// `legacy-unseal` matrix row is the fixture that proves it. + +import type { DeviceNamespace } from "./namespace.ts"; + +/** + * A refusal from the sealing layer, as a type rather than a string + * match. Every failure here is one of a small closed set, and callers + * (the unseal ceremony above all) must be able to tell "you typed the + * wrong passphrase" from "this device has no passphrase rung" from + * "these bytes have been tampered with" without parsing prose. + * + * SINCE THE PORT these codes are the lowered form of the component's + * `seal-error` variant (world.wit's `types`), which is the same closed + * set by construction — seal-component.ts's `sealErrorOf` is the one + * place the mapping lives. + */ +export class SealError extends Error { + constructor( + readonly code: + | "wrong-passphrase" + /** The passkey ceremony ran and the KEK it derived did not open + * the wrap — a wrong credential, a wrong PRF input, or a wrap + * record copied in from another device. Indistinguishable by + * construction, exactly as `wrong-passphrase` is. */ + | "wrong-passkey" + | "no-rung" + | "already-sealed" + | "tampered" + | "unsupported", + message: string, + ) { + super(message); + this.name = "SealError"; + } +} + +// --- the stored shapes ------------------------------------------------------ +// +// All three live in the namespace's `seal` store, which rests UNSEALED +// (it is what the seal is made of). A reader of this store learns: that +// the device has a passphrase rung, its KDF parameters, and 40-odd bytes +// of wrapped key. That is the whole exposure, and it is the exposure the +// passphrase's strength is measured against. + +export const KEY_PASSPHRASE_WRAP = "wrap:passphrase"; +export const KEY_PLATFORM_WRAP = "wrap:platform"; +export const KEY_PLATFORM_KEK = "kek:platform"; +export const KEY_PRF_WRAP = "wrap:prf"; + +/** The `seal` store, by name — the store every key above rests in. */ +export const SEAL_STORE = "seal"; +/** The `sealed` store: the key/value surface under the DEK. */ +export const SEALED_STORE = "sealed"; +/** The `identity` store: the device's persisted signing handles. */ +export const IDENTITY_STORE = "identity"; + +export interface PassphraseWrap { + v: 1; + kdf: "PBKDF2-SHA-256"; + iterations: number; + salt: Uint8Array; + /** The DEK wrapped with AES-KW under the derived KEK. */ + wrapped: Uint8Array; + /** + * WHETHER ANYBODY KNOWS THIS PASSPHRASE. + * + * `user` — a person chose it and can type it again. `generated` — it + * was minted from random bytes and dropped on the floor, which is how + * a T0 device is sealed with no ceremony (worker.ts's `sealT0`: "a + * door with no key"). + * + * IT HAS TO BE RECORDED, because the two are otherwise + * indistinguishable: `sealState` can say a passphrase rung EXISTS but + * not that it is reachable, and the index's policy tag does not answer + * it either — a device may sit on `until-reseal` and ALSO have the + * user's own passphrase (that is what `enableUntilReseal` being + * ADDITIVE means). Deleting the platform wrap on a device whose only + * rung is `generated` would destroy it, so the ceremony that deletes + * that wrap needs this bit to know whether to ask for a replacement + * first. + * + * ABSENT MEANS `generated`, deliberately: the failure modes are not + * symmetric. Reading an unmarked rung as reachable risks destroying a + * device; reading it as unreachable costs one ceremony nobody needed. + * + * The COMPONENT owns that rule now (`option` with + * `none` reading as `generated`); the codec's whole job is to let an + * absent field cross as `none` rather than inventing a default here. + */ + origin?: "user" | "generated"; +} + +export interface PlatformWrap { + v: 1; + /** The DEK wrapped with AES-KW under the non-extractable platform + * key stored beside it. */ + wrapped: Uint8Array; +} + +/** + * THE PASSKEY RUNG'S RECORD (PERSISTENCE.md, "The PRF rung: passkey + * unseal"). A sibling of `PassphraseWrap`, with the same at-rest + * posture and a different honest sentence. + * + * WHAT A READER OF THIS STORE LEARNS, stated as plainly as the + * passphrase wrap's exposure is stated above: that the device has a + * passkey rung; WHICH credential opens it (`credentialId` plus the + * `transports` routing hints and the `rpId` — an identifier and where + * to look for it, not secrets); the two fresh-random 32-byte salts; + * and 40-odd bytes of wrapped key. Unlike the passphrase wrap there is + * NO HUMAN-CHOSEN SECRET behind those bytes to guess at offline: the + * key material rests in the authenticator, which demands presence and + * verification per ceremony, so possession of this record is not the + * start of an attack the way a passphrase wrap is. + * + * NO `origin` FIELD, deliberately, and the absence is load-bearing. + * `PassphraseWrap` needs one because a passphrase rung may be a door + * with no key (`sealT0`'s generated wrap). A PRF rung cannot be: it + * only ever exists because a person ran an enrollment ceremony on an + * authenticator they hold, so it is ALWAYS a door somebody can walk + * through. That is the fact the reseal-upgrade guard consults + * (worker.ts's `reseal`), and it is true by construction rather than + * by a recorded bit. + */ +export interface PrfWrap { + v: 1; + /** Names THIS construction, so a later rung (a rotated input, a + * different KDF) is told apart rather than guessed at — the `kdf` + * tag's job on the passphrase wrap too. */ + kdf: "prf-hkdf-sha-256"; + credentialId: Uint8Array; + transports?: string[]; + rpId: string; + /** The 32 random bytes handed to the PRF extension as `eval.first`. + * Fresh per wrap; not a secret (the authenticator's per-credential + * key is what makes the output unpredictable). */ + prfInput: Uint8Array; + /** HKDF's salt, 32 fresh random bytes. */ + hkdfSalt: Uint8Array; + /** The DEK wrapped with AES-KW under the derived KEK. */ + wrapped: Uint8Array; +} + +/** + * What the PAGE hands the worker beside the KEK at enrollment, and + * what it reads back (`getPrfEnrollment`) to run an unseal assertion. + * + * It is `PrfWrap` MINUS the wrapped bytes: the ceremony half of the + * record and nothing that a page has any use for. The page cannot + * unwrap anyway — the DEK never crosses to it — so handing it the wrap + * would be exposure bought for nothing. + */ +export interface PrfEnrollment { + credentialId: Uint8Array; + transports?: string[]; + rpId: string; + prfInput: Uint8Array; + hkdfSalt: Uint8Array; +} + +/** AES-GCM's IV: 96 bits, FRESH PER WRITE. Reuse under one key is the + * failure mode that loses both confidentiality and integrity for GCM, + * so it is generated at the write and stored beside the ciphertext, + * never derived from the key name or a counter. */ +export interface SealedValue { + v: 1; + iv: Uint8Array; + ct: Uint8Array; +} + +/** What rungs this device actually has — the picker's question, asked + * without opening anything. */ +export interface SealState { + /** A passphrase rung EXISTS. It says nothing about whether anybody + * knows the passphrase — see `userPassphrase`. */ + passphrase: boolean; + /** A passphrase rung exists AND a person chose it, so it is a door + * somebody can actually walk through. This is the bit a ceremony that + * deletes the platform wrap has to consult (`PassphraseWrap.origin`). */ + userPassphrase: boolean; + /** This device auto-unseals from the platform key until reseal. */ + untilReseal: boolean; + /** This device has a passkey rung. Unlike `passphrase`, this bit + * needs no companion "does anybody know it": a PRF rung is always + * reachable by whoever holds the authenticator (see `PrfWrap`). */ + prf: boolean; +} + +/** + * The ceremony metadata the page needs to run an unseal assertion: + * which credential, where to look for it, and the PRF input to ask it + * to evaluate. The WRAPPED BYTES ARE NOT RETURNED — the page has no use + * for them and no way to open them. + * + * `undefined` when this device has no passkey rung, which is the normal + * case rather than an error (the picker asks this to decide whether to + * offer the button). + * + * IT STAYED IN TYPESCRIPT, and deliberately: PERSISTENCE.md's "Unseal" + * has the PAGE read this record before anything is open, to decide + * whether to offer the passkey button at all. Nothing here is + * cryptographic — it is the wrap record minus its only secret-adjacent + * field — so routing it through the component would mean instantiating + * a seal to answer a question about whether to offer a button. The + * component's own `get-prf-enrollment` serves the WORKER's copy of the + * same question; this serves the page's. + * + * VALIDATE-ON-LOAD: the `seal` store is writable by anything else on + * this origin, so a record read back out is untrusted input. A malformed + * one is refused as `tampered` rather than handed to a ceremony that + * would then ask an authenticator to evaluate whatever bytes were + * planted in it. + */ +export async function getPrfEnrollment( + ns: DeviceNamespace, +): Promise { + const rec = await readPrfWrap(ns); + if (!rec) return undefined; + const out: PrfEnrollment = { + credentialId: rec.credentialId, + rpId: rec.rpId, + prfInput: rec.prfInput, + hkdfSalt: rec.hkdfSalt, + }; + if (rec.transports?.length) out.transports = rec.transports; + return out; +} + +/** + * Load the PRF wrap and validate its shape. The salts are pinned at the + * length this construction writes (32 bytes): a planted 1-byte input + * would otherwise reach an authenticator ceremony before anything + * refused it. + */ +async function readPrfWrap(ns: DeviceNamespace): Promise { + const rec = await ns.get(SEAL_STORE, KEY_PRF_WRAP); + if (!rec) return undefined; + const bytes = (v: unknown): v is Uint8Array => v instanceof Uint8Array && v.length > 0; + const salt = (v: unknown): v is Uint8Array => v instanceof Uint8Array && v.length === 32; + const ok = rec.v === 1 && rec.kdf === "prf-hkdf-sha-256" && + bytes(rec.credentialId) && salt(rec.prfInput) && salt(rec.hkdfSalt) && + bytes(rec.wrapped) && + typeof rec.rpId === "string" && rec.rpId.length > 0 && + (rec.transports === undefined || + (Array.isArray(rec.transports) && rec.transports.every((t) => typeof t === "string"))); + if (!ok) throw new SealError("tampered", "this device's passkey rung record is not readable"); + return rec; +} diff --git a/runtime/device-store/seal.ts b/runtime/device-store/seal.ts deleted file mode 100644 index 6c812cd8..00000000 --- a/runtime/device-store/seal.ts +++ /dev/null @@ -1,837 +0,0 @@ -// SEALING: the per-device DEK and the KEK ladder (PERSISTENCE.md, -// "Sealing"). -// -// One data key per device — AES-GCM-256, random — under which the -// device's bulk state rests: keyhive archive, checkpoint blobs, us-doc -// working state, visor cache. Everything above this module (the sealed -// KV surface below, sealed-fs.ts) encrypts with that one key; this -// module's job is only to decide WHO can get it back, and how often -// they have to prove it. -// -// THE DEK'S RAW BYTES NEVER ENTER JS. It is generated inside WebCrypto, -// wrapped with `wrapKey` and recovered with `unwrapKey`; both operations -// keep the material on the platform's side of the boundary. Every DEK -// handle this module HANDS OUT is `extractable: false`, so the next -// track can park one in worker memory (the `while-open` rung) without -// that handle being a bearer secret. The one exception is spelled out -// at `wrappableDek` — a ceremony that re-wraps has to be able to wrap. -// -// KEK LADDER, v1 (the table in PERSISTENCE.md, "Sealing"): -// -// every-session passphrase → PBKDF2-SHA-256 → AES-KW over the DEK. -// The real tier. Nothing persisted can open it. -// while-open not this track's: the unwrapped DEK is simply held -// in the worker and dies with it. What this module -// contributes is that the handle it returns is safe to -// hold (non-extractable). -// until-reseal the DEK additionally wrapped by a NON-EXTRACTABLE -// platform key living as a structured-cloned handle in -// the namespace. See `enableUntilReseal` for the -// honest sentence. -// passkey the DEK wrapped under a KEK derived from a WebAuthn -// PRF output — HKDF-SHA-256 → AES-KW, the passphrase -// rung's ladder with the human secret replaced by a -// credential the authenticator gates behind presence -// plus verification. The CEREMONY IS NOT HERE: it -// cannot be, because `navigator.credentials` is -// window-only. This module only ever sees the derived -// KEK handle (passkey.ts is the window half; the -// design record is PERSISTENCE.md's "The PRF rung: -// passkey unseal"). -// -// Argon2 is a RECORDED FUTURE RUNG, not this module's: each wrap record -// carries a `kdf` tag so a later rung can be told apart from these -// rather than guessed at. - -import type { DeviceNamespace } from "./namespace.ts"; - -/** - * A refusal from the sealing layer, as a type rather than a string - * match. Every failure here is one of a small closed set, and callers - * (the unseal ceremony above all) must be able to tell "you typed the - * wrong passphrase" from "this device has no passphrase rung" from - * "these bytes have been tampered with" without parsing prose. - */ -export class SealError extends Error { - constructor( - readonly code: - | "wrong-passphrase" - /** The passkey ceremony ran and the KEK it derived did not open - * the wrap — a wrong credential, a wrong PRF input, or a wrap - * record copied in from another device. Indistinguishable by - * construction, exactly as `wrong-passphrase` is. */ - | "wrong-passkey" - | "no-rung" - | "already-sealed" - | "tampered" - | "unsupported", - message: string, - ) { - super(message); - this.name = "SealError"; - } -} - -// --- the stored shapes ------------------------------------------------------ -// -// All three live in the namespace's `seal` store, which rests UNSEALED -// (it is what the seal is made of). A reader of this store learns: that -// the device has a passphrase rung, its KDF parameters, and 40-odd bytes -// of wrapped key. That is the whole exposure, and it is the exposure the -// passphrase's strength is measured against. - -const KEY_PASSPHRASE_WRAP = "wrap:passphrase"; -const KEY_PLATFORM_WRAP = "wrap:platform"; -const KEY_PLATFORM_KEK = "kek:platform"; -const KEY_PRF_WRAP = "wrap:prf"; - -/** PBKDF2 parameters, v1. 600k iterations is OWASP's 2023 floor for - * PBKDF2-HMAC-SHA-256; the salt is 16 fresh random bytes per wrap, so - * two devices (and two re-keys of one device) never share a derivation. - * These are RECORDED IN THE RECORD, not assumed at read time, so - * raising the count later does not orphan existing devices. */ -const PBKDF2_ITERATIONS = 600_000; -const SALT_BYTES = 16; - -interface PassphraseWrap { - v: 1; - kdf: "PBKDF2-SHA-256"; - iterations: number; - salt: Uint8Array; - /** The DEK wrapped with AES-KW under the derived KEK. */ - wrapped: Uint8Array; - /** - * WHETHER ANYBODY KNOWS THIS PASSPHRASE. - * - * `user` — a person chose it and can type it again. `generated` — it - * was minted from random bytes and dropped on the floor, which is how - * a T0 device is sealed with no ceremony (worker.ts's `sealT0`: "a - * door with no key"). - * - * IT HAS TO BE RECORDED, because the two are otherwise - * indistinguishable: `sealState` can say a passphrase rung EXISTS but - * not that it is reachable, and the index's policy tag does not answer - * it either — a device may sit on `until-reseal` and ALSO have the - * user's own passphrase (that is what `enableUntilReseal` being - * ADDITIVE means). Deleting the platform wrap on a device whose only - * rung is `generated` would destroy it, so the ceremony that deletes - * that wrap needs this bit to know whether to ask for a replacement - * first. - * - * ABSENT MEANS `generated`, deliberately: the failure modes are not - * symmetric. Reading an unmarked rung as reachable risks destroying a - * device; reading it as unreachable costs one ceremony nobody needed. - */ - origin?: "user" | "generated"; -} - -interface PlatformWrap { - v: 1; - /** The DEK wrapped with AES-KW under the non-extractable platform - * key stored beside it. */ - wrapped: Uint8Array; -} - -/** - * THE PASSKEY RUNG'S RECORD (PERSISTENCE.md, "The PRF rung: passkey - * unseal"). A sibling of `PassphraseWrap`, with the same at-rest - * posture and a different honest sentence. - * - * WHAT A READER OF THIS STORE LEARNS, stated as plainly as the - * passphrase wrap's exposure is stated above: that the device has a - * passkey rung; WHICH credential opens it (`credentialId` plus the - * `transports` routing hints and the `rpId` — an identifier and where - * to look for it, not secrets); the two fresh-random 32-byte salts; - * and 40-odd bytes of wrapped key. Unlike the passphrase wrap there is - * NO HUMAN-CHOSEN SECRET behind those bytes to guess at offline: the - * key material rests in the authenticator, which demands presence and - * verification per ceremony, so possession of this record is not the - * start of an attack the way a passphrase wrap is. - * - * NO `origin` FIELD, deliberately, and the absence is load-bearing. - * `PassphraseWrap` needs one because a passphrase rung may be a door - * with no key (`sealT0`'s generated wrap). A PRF rung cannot be: it - * only ever exists because a person ran an enrollment ceremony on an - * authenticator they hold, so it is ALWAYS a door somebody can walk - * through. That is the fact the reseal-upgrade guard consults - * (worker.ts's `reseal`), and it is true by construction rather than - * by a recorded bit. - */ -interface PrfWrap { - v: 1; - /** Names THIS construction, so a later rung (a rotated input, a - * different KDF) is told apart rather than guessed at — the `kdf` - * tag's job on the passphrase wrap too. */ - kdf: "prf-hkdf-sha-256"; - credentialId: Uint8Array; - transports?: string[]; - rpId: string; - /** The 32 random bytes handed to the PRF extension as `eval.first`. - * Fresh per wrap; not a secret (the authenticator's per-credential - * key is what makes the output unpredictable). */ - prfInput: Uint8Array; - /** HKDF's salt, 32 fresh random bytes. */ - hkdfSalt: Uint8Array; - /** The DEK wrapped with AES-KW under the derived KEK. */ - wrapped: Uint8Array; -} - -/** - * What the PAGE hands the worker beside the KEK at enrollment, and - * what it reads back (`getPrfEnrollment`) to run an unseal assertion. - * - * It is `PrfWrap` MINUS the wrapped bytes: the ceremony half of the - * record and nothing that a page has any use for. The page cannot - * unwrap anyway — the DEK never crosses to it — so handing it the wrap - * would be exposure bought for nothing. - */ -export interface PrfEnrollment { - credentialId: Uint8Array; - transports?: string[]; - rpId: string; - prfInput: Uint8Array; - hkdfSalt: Uint8Array; -} - -/** What rungs this device actually has — the picker's question, asked - * without opening anything. */ -export interface SealState { - /** A passphrase rung EXISTS. It says nothing about whether anybody - * knows the passphrase — see `userPassphrase`. */ - passphrase: boolean; - /** A passphrase rung exists AND a person chose it, so it is a door - * somebody can actually walk through. This is the bit a ceremony that - * deletes the platform wrap has to consult (`PassphraseWrap.origin`). */ - userPassphrase: boolean; - /** This device auto-unseals from the platform key until reseal. */ - untilReseal: boolean; - /** This device has a passkey rung. Unlike `passphrase`, this bit - * needs no companion "does anybody know it": a PRF rung is always - * reachable by whoever holds the authenticator (see `PrfWrap`). */ - prf: boolean; -} - -export async function sealState(ns: DeviceNamespace): Promise { - const [p, k, r] = await Promise.all([ - ns.get("seal", KEY_PASSPHRASE_WRAP), - ns.get("seal", KEY_PLATFORM_WRAP), - ns.get("seal", KEY_PRF_WRAP), - ]); - return { - passphrase: p !== undefined, - userPassphrase: p !== undefined && p.origin === "user", - untilReseal: k !== undefined, - prf: r !== undefined, - }; -} - -// --- the DEK ---------------------------------------------------------------- - -const DEK_ALGORITHM = { name: "AES-GCM", length: 256 } as const; -const KW = { name: "AES-KW" } as const; - -/** - * AES-KW, not AES-GCM, for both wraps. - * - * Two reasons. It is deterministic, so a wrap needs no IV stored beside - * it and no IV-reuse hazard exists across re-wraps of the same key. And - * it is authenticated by construction (RFC 3394's integrity check - * value), which is what turns a wrong passphrase into a CLEAN REFUSAL: - * the unwrap fails inside WebCrypto and no partial key ever exists to - * be mistaken for a real one. A wrong passphrase and a corrupted wrap - * are indistinguishable here, deliberately — neither tells an attacker - * anything about the other. - */ -async function kekFromPassphrase( - passphrase: string, - salt: Uint8Array, - iterations: number, -): Promise { - const material = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(passphrase) as BufferSource, - "PBKDF2", - false, - ["deriveKey"], - ); - return await crypto.subtle.deriveKey( - { name: "PBKDF2", salt: salt as BufferSource, iterations, hash: "SHA-256" }, - material, - // `length` is REQUIRED for a derived AES key even though AES-KW-256 - // is implied by the usages: Chromium throws - // "AesDerivedKeyParams: length: Missing required property" without - // it (observed, first run of the probe matrix). - { name: "AES-KW", length: 256 }, - // The KEK itself is never read back either; it exists to wrap. - false, - ["wrapKey", "unwrapKey"], - ); -} - -/** Recover the DEK as a handle the caller may HOLD but not read. This - * is what every unseal path returns. */ -function unwrapDek(wrapped: Uint8Array, kek: CryptoKey, extractable: boolean): Promise { - return crypto.subtle.unwrapKey( - "raw", - wrapped as BufferSource, - kek, - KW, - DEK_ALGORITHM, - extractable, - ["encrypt", "decrypt"], - ); -} - -const wrapDek = async (dek: CryptoKey, kek: CryptoKey): Promise => - new Uint8Array(await crypto.subtle.wrapKey("raw", dek, kek, KW)); - -/** - * THE ONE PLACE A WRAPPABLE DEK EXISTS. - * - * `wrapKey` exports the key internally, so a key that is to be wrapped - * must have been created `extractable: true`. Every ceremony that adds - * or rotates a rung therefore needs the DEK in that form for the length - * of the ceremony and no longer. Two properties keep this honest: - * - * * the wrappable handle is a LOCAL, dropped when the ceremony - * returns; what the caller gets back is always the non-extractable - * handle from `unwrapDek(..., false)`. - * * even here the raw bytes stay inside WebCrypto — `extractable: - * true` means `exportKey` WOULD work, not that anything calls it. - * Nothing in this repo calls it (demo/scripts/check-invariants.sh - * invariant (d) bans the verb outright). - */ -async function wrappableDek(ns: DeviceNamespace, passphrase: string): Promise { - const rec = await ns.get("seal", KEY_PASSPHRASE_WRAP); - if (!rec) throw new SealError("no-rung", "this device has no passphrase rung"); - const kek = await kekFromPassphrase(passphrase, rec.salt, rec.iterations); - try { - return await unwrapDek(rec.wrapped, kek, true); - } catch { - throw new SealError("wrong-passphrase", "the passphrase did not open this device"); - } -} - -/** - * Mint this device's DEK and seal it under a passphrase — the - * `every-session` rung, and the promotion moment's default. - * - * REFUSES on a device that already has a rung rather than replacing it: - * a second mint would produce a second DEK, and every byte written - * under the first would become unreadable with no error anywhere. If a - * caller genuinely wants a new device, that is `createDevice`. - */ -export async function createSealedDek( - ns: DeviceNamespace, - passphrase: string, - /** Whether a PERSON chose `passphrase`. `generated` is for the - * no-ceremony T0 seal and nothing else — see `PassphraseWrap.origin` - * for why the distinction has to be durable. */ - origin: "user" | "generated" = "user", -): Promise { - if ((await sealState(ns)).passphrase) { - throw new SealError("already-sealed", "this device already has a passphrase rung"); - } - requirePassphrase(passphrase); - const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); - const kek = await kekFromPassphrase(passphrase, salt, PBKDF2_ITERATIONS); - const dek = await crypto.subtle.generateKey(DEK_ALGORITHM, true, ["encrypt", "decrypt"]); - const wrapped = await wrapDek(dek, kek); - const record: PassphraseWrap = { - v: 1, - kdf: "PBKDF2-SHA-256", - iterations: PBKDF2_ITERATIONS, - salt, - wrapped, - origin, - }; - await ns.put("seal", KEY_PASSPHRASE_WRAP, record); - // Hand back the SAME key as a non-extractable handle rather than the - // wrappable local: the caller is going to hold this for a session. - return await unwrapDek(wrapped, kek, false); -} - -/** An empty passphrase is not a rung, it is the absence of one wearing - * a rung's costume. Refuse it at the door rather than deriving a KEK - * anyone can reproduce. */ -function requirePassphrase(passphrase: string): void { - if (passphrase.length === 0) { - throw new SealError("unsupported", "an empty passphrase cannot seal a device"); - } -} - -/** - * THE LOGIN: open the device with the passphrase. The returned handle - * is non-extractable and is the whole unsealed state — dropping it - * re-seals the device as far as this tab is concerned. - */ -export async function unsealWithPassphrase( - ns: DeviceNamespace, - passphrase: string, -): Promise { - const rec = await ns.get("seal", KEY_PASSPHRASE_WRAP); - if (!rec) throw new SealError("no-rung", "this device has no passphrase rung"); - const kek = await kekFromPassphrase(passphrase, rec.salt, rec.iterations); - try { - return await unwrapDek(rec.wrapped, kek, false); - } catch { - // No partial state: nothing was written, nothing was cached, and - // the caller learns exactly one bit. - throw new SealError("wrong-passphrase", "the passphrase did not open this device"); - } -} - -/** - * Change the passphrase. THE SALT ROTATES: a re-key that kept the old - * salt would leave any precomputation against the old passphrase - * partly valid against the new one, and would make the two wraps - * visibly related in a store that rests in the clear. - * - * The DEK itself does NOT rotate, and that is deliberate: rotating it - * would mean re-encrypting every sealed byte the device holds, and the - * threat this rung answers (someone has your profile and is guessing) - * is answered by the new derivation, not by new data keys. - */ -export async function rekeyPassphrase( - ns: DeviceNamespace, - oldPassphrase: string, - newPassphrase: string, -): Promise { - requirePassphrase(newPassphrase); - const dek = await wrappableDek(ns, oldPassphrase); - const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); - const kek = await kekFromPassphrase(newPassphrase, salt, PBKDF2_ITERATIONS); - const record: PassphraseWrap = { - v: 1, - kdf: "PBKDF2-SHA-256", - iterations: PBKDF2_ITERATIONS, - salt, - wrapped: await wrapDek(dek, kek), - // A person chose this one, whatever the rung it replaces was. - origin: "user", - }; - // One write, after every fallible step has already succeeded: a - // failed re-key leaves the old passphrase working. - await ns.put("seal", KEY_PASSPHRASE_WRAP, record); -} - -// --- the `until-reseal` rung ------------------------------------------------ - -/** - * Turn on auto-unseal until the user explicitly reseals. - * - * THE HONEST SENTENCE, and the UI must say it: this is LOGIN - * CONVENIENCE, NOT PROTECTION AGAINST SOMEONE HOLDING YOUR PROFILE. The - * wrapping key is a non-extractable platform key, so the DEK cannot be - * lifted out of the browser profile as bytes — but anything that can - * run script on this origin in this profile can ask the platform to - * unwrap, exactly as the app does. The tier therefore degrades to - * profile access control (PERSISTENCE.md's ladder table), and `reseal` - * deletes the wrap. - * - * It is ADDITIVE: the passphrase rung stays, because it is the only - * thing that can open the device after a reseal. - */ -export async function enableUntilReseal( - ns: DeviceNamespace, - passphrase: string, -): Promise { - const dek = await wrappableDek(ns, passphrase); - // Non-extractable, structured-cloned into the namespace: the wosh - // handle-persistence pattern (identity-keys.ts documents it at - // length). `wrapKey`/`unwrapKey` are its only usages — it cannot - // encrypt data, only hold the DEK. - const kek = await crypto.subtle.generateKey({ name: "AES-KW", length: 256 }, false, [ - "wrapKey", - "unwrapKey", - ]) as CryptoKey; - const wrapped = await wrapDek(dek, kek); - // Handle first, then the wrap: the pair is only meaningful together, - // and a wrap with no key is the state that would make `unsealFromPlatform` - // report a rung it cannot actually use. - await ns.put("seal", KEY_PLATFORM_KEK, kek); - await ns.put("seal", KEY_PLATFORM_WRAP, { v: 1, wrapped } satisfies PlatformWrap); -} - -/** - * THE PROMOTION SEAM: give this device a passphrase rung it did not - * choose for itself, authorized by the PLATFORM rung it already has. - * - * WHY IT HAS TO EXIST, and why `rekeyPassphrase` could not do the job. - * A T0 device is sealed with no ceremony (worker.ts's `sealT0`): the - * passphrase rung it carries was minted from 32 random bytes that were - * then dropped on the floor, so nobody — including this worker after a - * reload — can reproduce it. When the user later says "keep this - * device" and chooses `every-session`, the DEK has to be re-wrapped - * under THEIR passphrase, and there is no old passphrase to present. - * The DEK handle the worker is holding cannot stand in for one either: - * every handle this module hands out is `extractable: false`, and - * `wrapKey` needs an extractable key. So the authorization comes from - * the one door that IS open on a T0 device — the platform wrap. - * - * WHAT THIS IS AUTHORIZED BY, stated plainly: possession of the - * profile. That is exactly the `until-reseal` tier's honest strength - * (PERSISTENCE.md's ladder), and it is not a widening: anything that - * can call this could equally call `unsealFromPlatform` and read the - * device. It refuses outright when the platform rung is absent, so a - * device that has already been resealed cannot be re-keyed this way — - * that one needs its passphrase, which is what reseal is for. - * - * The salt rotates and the DEK does not, for `rekeyPassphrase`'s - * reasons. The single write lands after every fallible step. - */ -export async function rekeyFromPlatform( - ns: DeviceNamespace, - newPassphrase: string, -): Promise { - requirePassphrase(newPassphrase); - const dek = await wrappableDekFromPlatform(ns); - const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); - const kek = await kekFromPassphrase(newPassphrase, salt, PBKDF2_ITERATIONS); - const record: PassphraseWrap = { - v: 1, - kdf: "PBKDF2-SHA-256", - iterations: PBKDF2_ITERATIONS, - salt, - wrapped: await wrapDek(dek, kek), - // THE POINT OF THIS CEREMONY: what it leaves behind is a rung - // somebody knows, where a moment ago there was only a door with no - // key. - origin: "user", - }; - await ns.put("seal", KEY_PASSPHRASE_WRAP, record); -} - -/** - * The platform rung's `wrappableDek`. Same discipline as that one: the - * extractable handle is a LOCAL of the ceremony that needs it, the raw - * bytes never enter JS, and nothing in this repo calls `exportKey` - * (demo/scripts/check-invariants.sh invariant (d)). - */ -async function wrappableDekFromPlatform(ns: DeviceNamespace): Promise { - const [rec, kek] = await Promise.all([ - ns.get("seal", KEY_PLATFORM_WRAP), - ns.get("seal", KEY_PLATFORM_KEK), - ]); - if (!rec || !kek) { - throw new SealError("no-rung", "this device has no platform rung to re-key from"); - } - // Validate-on-load, for `unsealFromPlatform`'s reason: a planted - // EXTRACTABLE key here would be an attacker's handle we then used to - // unwrap the DEK. - if (!(kek instanceof CryptoKey) || kek.extractable !== false || kek.algorithm.name !== "AES-KW") { - throw new SealError( - "tampered", - "the persisted platform key is not a usable non-extractable AES-KW key", - ); - } - try { - return await unwrapDek(rec.wrapped, kek, true); - } catch { - throw new SealError("tampered", "the platform wrap did not open"); - } -} - -/** - * Auto-unseal, if this device has the `until-reseal` rung. Returns - * `null` when it does not — a device that must be asked for its - * passphrase is the normal case, not an error. - */ -export async function unsealFromPlatform(ns: DeviceNamespace): Promise { - const [rec, kek] = await Promise.all([ - ns.get("seal", KEY_PLATFORM_WRAP), - ns.get("seal", KEY_PLATFORM_KEK), - ]); - if (!rec || !kek) return null; - // Validate-on-load, for identity-keys.ts's reason: IndexedDB is - // writable by anything else on this origin, so a stored key is - // untrusted input on the way back in. A planted EXTRACTABLE key here - // would be an attacker's handle we then used to unwrap the DEK. - if (!(kek instanceof CryptoKey) || kek.extractable !== false || kek.algorithm.name !== "AES-KW") { - throw new SealError("tampered", "the persisted platform key is not a usable non-extractable AES-KW key"); - } - try { - return await unwrapDek(rec.wrapped, kek, false); - } catch { - throw new SealError("tampered", "the platform wrap did not open"); - } -} - -// --- the `passkey` rung ----------------------------------------------------- -// -// THE WORKER'S HALF, AND ONLY THAT HALF. WebAuthn cannot run here -// (`navigator.credentials` is window-only), so the assertion runs on the -// PAGE, the page derives the KEK, and what reaches this module is the -// NON-EXTRACTABLE AES-KW handle — a CryptoKey structured-clones through -// `postMessage` exactly as it does into IndexedDB (spikes/prf-unseal, -// row 9). The raw PRF output never comes near this module. - -/** - * VALIDATE THE CROSSED KEK BEFORE USING IT — the same refusal - * `unsealFromPlatform` makes about a persisted platform key, for the - * same reason wearing different clothes. - * - * A key that arrived from somewhere else is untrusted input. There it - * arrives from IndexedDB, which anything on this origin may write; here - * it arrives over the port from the page, which anything running on this - * origin may hold. Either way an EXTRACTABLE key, or one whose algorithm - * is not AES-KW, is not the handle this ceremony was designed around, and - * using it anyway would mean wrapping the device's DEK under something - * whose material can be read back. So it is refused as `tampered` rather - * than coerced. - * - * Both usages matter and both are checked at the operation, not here: - * enrollment needs `wrapKey`, unseal needs `unwrapKey`, and passkey.ts - * derives with both — WebCrypto raises on a usage the key does not have, - * which is a refusal in its own right. - */ -function requirePrfKek(kek: CryptoKey): void { - if (!(kek instanceof CryptoKey) || kek.extractable !== false || kek.algorithm.name !== "AES-KW") { - throw new SealError( - "tampered", - "the passkey KEK handed to this ceremony is not a usable non-extractable AES-KW key", - ); - } -} - -/** - * The ceremony metadata the page needs to run an unseal assertion: - * which credential, where to look for it, and the PRF input to ask it - * to evaluate. The WRAPPED BYTES ARE NOT RETURNED — the page has no use - * for them and no way to open them. - * - * `undefined` when this device has no passkey rung, which is the normal - * case rather than an error (the picker asks this to decide whether to - * offer the button). - * - * VALIDATE-ON-LOAD, for `unsealFromPlatform`'s reason: the `seal` store - * is writable by anything else on this origin, so a record read back out - * is untrusted input. A malformed one is refused as `tampered` rather - * than handed to a ceremony that would then ask an authenticator to - * evaluate whatever bytes were planted in it. - */ -export async function getPrfEnrollment( - ns: DeviceNamespace, -): Promise { - const rec = await readPrfWrap(ns); - if (!rec) return undefined; - const out: PrfEnrollment = { - credentialId: rec.credentialId, - rpId: rec.rpId, - prfInput: rec.prfInput, - hkdfSalt: rec.hkdfSalt, - }; - if (rec.transports?.length) out.transports = rec.transports; - return out; -} - -/** - * Load the PRF wrap and validate its shape — the ONE reader both - * ceremonies go through, so a planted record is refused identically - * whether the page is about to run an assertion or the worker is about - * to unwrap. The salts are pinned at the length this construction - * writes (32 bytes): a planted 1-byte input would otherwise reach an - * authenticator ceremony before anything refused it. - */ -async function readPrfWrap(ns: DeviceNamespace): Promise { - const rec = await ns.get("seal", KEY_PRF_WRAP); - if (!rec) return undefined; - const bytes = (v: unknown): v is Uint8Array => v instanceof Uint8Array && v.length > 0; - const salt = (v: unknown): v is Uint8Array => v instanceof Uint8Array && v.length === 32; - const ok = rec.v === 1 && rec.kdf === "prf-hkdf-sha-256" && - bytes(rec.credentialId) && salt(rec.prfInput) && salt(rec.hkdfSalt) && - bytes(rec.wrapped) && - typeof rec.rpId === "string" && rec.rpId.length > 0 && - (rec.transports === undefined || - (Array.isArray(rec.transports) && rec.transports.every((t) => typeof t === "string"))); - if (!ok) throw new SealError("tampered", "this device's passkey rung record is not readable"); - return rec; -} - -/** - * ADD THE PASSKEY RUNG: re-wrap this device's DEK under a KEK the page - * derived from a passkey's PRF output. - * - * WHAT AUTHORIZES IT, stated as plainly as `rekeyFromPlatform` states - * its own. Preferentially the PLATFORM rung — a device at the promotion - * moment always has one, and its existing passphrase rung may well be - * the door with no key `sealT0` left behind. That authorization is - * possession of the profile, which is exactly the `until-reseal` tier's - * honest strength and not a widening: anything that could call this - * could equally call `unsealFromPlatform` and read the device. When the - * platform rung is gone (a resealed device being switched to passkey - * unseal on the this-device sheet) the authority is the PASSPHRASE, - * which the sheet asks for and which is that device's login anyway. - * With neither, this refuses — there is no third authority, and a - * ceremony that re-wrapped a DEK on nobody's say-so would be one. - * - * IT DOES NOT DELETE THE PLATFORM WRAP. Shutting that door is the - * caller's half, exactly as it is for `every-session` (worker.ts's - * `promote` calls `reseal` after this returns): the decision "a user who - * asked to be asked must not leave a silent door standing" belongs to - * the ceremony that knows what the user chose, not to the re-wrap. - * - * ONE WRITE, after every fallible step has already succeeded — the - * assertion, the derivation, the unwrap and the re-wrap all happen - * first, so a failed enrollment leaves the device exactly as it was. - */ -export async function enablePrf( - ns: DeviceNamespace, - kek: CryptoKey, - enrollment: PrfEnrollment, - authz: { passphrase?: string }, -): Promise { - requirePrfKek(kek); - const rungs = await sealState(ns); - let dek: CryptoKey; - if (rungs.untilReseal) { - dek = await wrappableDekFromPlatform(ns); - } else if (authz.passphrase !== undefined) { - dek = await wrappableDek(ns, authz.passphrase); - } else { - throw new SealError( - "no-rung", - "enrolling a passkey needs an authority: this device has no platform rung, " + - "and no passphrase was offered", - ); - } - const record: PrfWrap = { - v: 1, - kdf: "prf-hkdf-sha-256", - credentialId: enrollment.credentialId, - rpId: enrollment.rpId, - prfInput: enrollment.prfInput, - hkdfSalt: enrollment.hkdfSalt, - wrapped: await wrapDek(dek, kek), - }; - if (enrollment.transports?.length) record.transports = enrollment.transports; - await ns.put("seal", KEY_PRF_WRAP, record); -} - -/** - * THE LOGIN, passkey flavour: open the device with the KEK the page - * derived from a fresh assertion. The returned handle is - * non-extractable and is the whole unsealed state, exactly as - * `unsealWithPassphrase`'s is. - * - * A FAILED UNWRAP IS ONE BIT, deliberately. AES-KW's integrity check - * fails inside WebCrypto and no partial key ever exists to be mistaken - * for a real one, so "a different credential answered", "the PRF input - * was not the one this wrap was made with" and "this record was copied - * in from another device" are indistinguishable here — the same - * property that makes a wrong passphrase a clean refusal. The last of - * those three is why the derivation binds the device id into HKDF's - * `info` (passkey.ts): a wrap carried between namespaces refuses HERE, - * as a typed `wrong-passkey`, instead of opening a foreign DEK and - * surfacing as GCM tamper noise somewhere downstream. - */ -export async function unsealWithPrf(ns: DeviceNamespace, kek: CryptoKey): Promise { - // The validated reader, so a MALFORMED record refuses as `tampered` - // here too — "someone altered the record" and "the right record, the - // wrong key" are different facts and get different codes. - const rec = await readPrfWrap(ns); - if (!rec) throw new SealError("no-rung", "this device has no passkey rung"); - requirePrfKek(kek); - try { - return await unwrapDek(rec.wrapped, kek, false); - } catch { - // Nothing was written and nothing cached; the caller learns exactly - // one bit. - throw new SealError("wrong-passkey", "that passkey did not open this device"); - } -} - -/** - * RESEAL (PERSISTENCE.md, "Unseal UX"): delete the persisted wrap and - * the platform key handle, so the next boot has to ask for the - * passphrase again. Telling the worker to drop its key material is the - * caller's other half — this is only the durable half. - * - * The handle goes too, not just the wrap. Leaving a non-extractable key - * lying in the namespace would leave the thing whose existence the user - * just asked to end. - * - * THE PASSKEY WRAP SURVIVES, and that is the design record's ruling - * (PERSISTENCE.md, "The PRF rung", "Reseal"): an assertion per unseal is - * that rung's whole point, so what it leaves behind opens nothing on its - * own — there is no door here to shut. - */ -export async function reseal(ns: DeviceNamespace): Promise { - await ns.delete("seal", KEY_PLATFORM_WRAP); - await ns.delete("seal", KEY_PLATFORM_KEK); -} - -// --- the sealed key/value surface ------------------------------------------- - -/** AES-GCM's IV: 96 bits, FRESH PER WRITE. Reuse under one key is the - * failure mode that loses both confidentiality and integrity for GCM, - * so it is generated at the write and stored beside the ciphertext, - * never derived from the key name or a counter. */ -const IV_BYTES = 12; - -interface SealedValue { - v: 1; - iv: Uint8Array; - ct: Uint8Array; -} - -/** - * Seal `bytes` under the device's DEK and store them at `key`. - * - * THE KEY NAME IS ADDITIONAL AUTHENTICATED DATA. It is not secret (it - * is the IndexedDB key, in the clear), but binding it means an attacker - * with write access to the namespace cannot move a valid sealed value - * from one name to another — a swap that would otherwise be undetectable - * because every value is sealed under the same DEK. - */ -export async function sealedPut( - ns: DeviceNamespace, - dek: CryptoKey, - key: string, - bytes: Uint8Array, -): Promise { - const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); - const ct = new Uint8Array( - await crypto.subtle.encrypt( - { name: "AES-GCM", iv: iv as BufferSource, additionalData: aad(key) }, - dek, - bytes as BufferSource, - ), - ); - await ns.put("sealed", key, { v: 1, iv, ct } satisfies SealedValue); -} - -/** - * Open the sealed value at `key`, or `undefined` if there is none. - * - * A value that is present but does not open throws `SealError - * "tampered"` rather than returning `undefined`: "nothing stored" and - * "stored, and altered underneath us" are different facts and the - * caller must not be able to confuse them by accident. GCM's tag is - * what makes the second one detectable at all. - */ -export async function sealedGet( - ns: DeviceNamespace, - dek: CryptoKey, - key: string, -): Promise { - const rec = await ns.get("sealed", key); - if (!rec) return undefined; - try { - return new Uint8Array( - await crypto.subtle.decrypt( - { name: "AES-GCM", iv: rec.iv as BufferSource, additionalData: aad(key) }, - dek, - rec.ct as BufferSource, - ), - ); - } catch { - throw new SealError("tampered", `the sealed value ${JSON.stringify(key)} did not open`); - } -} - -export function sealedDelete(ns: DeviceNamespace, key: string): Promise { - return ns.delete("sealed", key); -} - -const aad = (key: string): BufferSource => new TextEncoder().encode(key) as BufferSource; diff --git a/runtime/device-store/sealed-fs.ts b/runtime/device-store/sealed-fs.ts index 657e2df2..5439c33f 100644 --- a/runtime/device-store/sealed-fs.ts +++ b/runtime/device-store/sealed-fs.ts @@ -66,8 +66,11 @@ export class SealedFsError extends Error { /** Consumed by the provider's `mapError` (fs_provider.ts). */ readonly fsCode = "io"; - constructor(message: string) { - super(message); + /** `cause` carries the sealer's own refusal — the component's + * `seal-error`, lowered — so a debugger can still see WHICH refusal it + * was without that becoming part of the filesystem's contract. */ + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); this.name = "SealedFsError"; } } @@ -115,59 +118,32 @@ export interface OpfsDirectoryHandle { move?(parent: OpfsDirectoryHandle, name: string): Promise; } -// --- the on-disk shape ------------------------------------------------------ +// --- the sealing seam ------------------------------------------------------- -/** `PMSEALv1` — 8 ASCII bytes. Present so that a file which is NOT - * sealed is diagnosed as such instead of decrypted into noise, and - * AUTHENTICATED as AAD so the version cannot be downgraded by an editor - * of the raw bytes. */ -const MAGIC = new TextEncoder().encode("PMSEALv1"); -const IV_BYTES = 12; -const HEADER = MAGIC.length + IV_BYTES; -/** magic + iv + GCM tag: what an empty file costs once sealed. */ -const OVERHEAD = HEADER + 16; - -const gcm = (iv: Uint8Array): AesGcmParams => ({ - name: "AES-GCM", - iv: iv as BufferSource, - additionalData: MAGIC as BufferSource, -}); - -/** Seal a whole plaintext buffer. FRESH IV PER WRITE — every commit of - * a file generates one, so re-sealing the same file after a one-byte - * change never reuses an IV under the DEK. */ -async function sealBytes(dek: CryptoKey, plain: Uint8Array): Promise { - const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); - const ct = new Uint8Array(await crypto.subtle.encrypt(gcm(iv), dek, plain as BufferSource)); - const out = new Uint8Array(HEADER + ct.length); - out.set(MAGIC, 0); - out.set(iv, MAGIC.length); - out.set(ct, HEADER); - return out; -} - -async function openBytes(dek: CryptoKey, raw: Uint8Array, what: string): Promise { - // A ZERO-LENGTH file is an empty file, not a broken one: the - // provider's `openAt` creates the OPFS entry before anything is ever - // written to it (filesystem_web.ts's create path), so a legitimately - // empty file has no header at all. - if (raw.length === 0) return new Uint8Array(0); - if (raw.length < OVERHEAD || !eq(raw.subarray(0, MAGIC.length), MAGIC)) { - throw new SealedFsError(`${what}: not a sealed file`); - } - const iv = raw.subarray(MAGIC.length, HEADER); - try { - return new Uint8Array(await crypto.subtle.decrypt(gcm(iv), dek, raw.subarray(HEADER) as BufferSource)); - } catch { - // Wrong DEK and altered bytes are the same event to GCM, and are - // reported as one: neither tells the caller anything about the - // other. - throw new SealedFsError(`${what}: did not open under this device key (wrong key or altered bytes)`); - } +/** + * WHAT SEALS AND OPENS A FILE'S BYTES — the device seal component's + * `sealed` interface (world.wit:296-307), handed in rather than + * performed here. + * + * THE FORMAT MOVED AND THIS MODULE NO LONGER KNOWS IT. PMSEALv1's magic, + * its 12-byte IV, the additional data and the empty-file rule all live in + * the component (runtime/device-seal/src/file_format.rs, which cites the + * lines of this file they were ported from). What is left here is the + * PROXY: buffering, the plaintext view, growth and truncation, handle + * identity — everything that is about OPFS rather than about + * cryptography. + * + * The proxy passes bytes through UNCHANGED in both directions and adds no + * special case of its own, which is the same thing the deleted code did: + * `sealBytes` had no empty-plaintext branch (an empty file the guest + * actually wrote costs a full header and tag), and the zero-length read + * was `openBytes`'s rule, not the proxy's. Both are the component's now. + */ +export interface FileSealer { + sealFile(plaintext: Uint8Array): Promise; + openFile(sealed: Uint8Array): Promise; } -const eq = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]); - // --- the plaintext view ----------------------------------------------------- /** The `File`-shaped plaintext view the provider reads through. Its @@ -194,11 +170,40 @@ function plainFile(bytes: Uint8Array, lastModified: number): OpfsFileLike { }; } -function sealedFile(inner: OpfsFileHandle, dek: CryptoKey): OpfsFileHandle { +/** + * RE-TAG THE SEALER'S REFUSAL AS A FILESYSTEM ERROR. + * + * The component refuses a file that does not open with a `seal-error`, + * which the adapter lowers to a `SealError` — and a `SealError` carries + * no `fsCode`, so the polyengine provider's `mapError` would not + * recognize it and the host would explode where a filesystem should + * merely report an error. `SealedFsError` is what makes the guest see an + * I/O error instead, which is the whole reason that class has an + * `fsCode` at all; the wrapping keeps that contract across the move. + * + * A wrong DEK and altered bytes are the same event to GCM and were + * always reported as one, so nothing is lost by flattening the + * component's refusal into this sentence. + */ +async function asFsError(what: string, body: () => Promise): Promise { + try { + return await body(); + } catch (e) { + throw new SealedFsError( + `${what}: did not open under this device key (wrong key or altered bytes)`, + { cause: e }, + ); + } +} + +function sealedFile(inner: OpfsFileHandle, sealer: FileSealer): OpfsFileHandle { const read = async (): Promise<{ bytes: Uint8Array; lastModified: number }> => { const f = await inner.getFile(); const raw = new Uint8Array(await f.arrayBuffer()); - return { bytes: await openBytes(dek, raw, inner.name), lastModified: f.lastModified }; + return { + bytes: await asFsError(inner.name, () => sealer.openFile(raw)), + lastModified: f.lastModified, + }; }; const handle: OpfsFileHandle = { @@ -243,7 +248,7 @@ function sealedFile(inner: OpfsFileHandle, dek: CryptoKey): OpfsFileHandle { // underlying writable is opened WITHOUT keepExistingData, so // the previous ciphertext (a different length in general) can // never leave a tail behind the new one. - const raw = await sealBytes(dek, buffer); + const raw = await asFsError(inner.name, () => sealer.sealFile(buffer)); const w = await inner.createWritable({ keepExistingData: false }); try { await w.write({ type: "write", position: 0, data: raw }); @@ -272,24 +277,24 @@ function unwrap(h: OpfsFileHandle | OpfsDirectoryHandle): OpfsFileHandle | OpfsD } /** - * Wrap an OPFS directory so every file under it rests sealed under - * `dek` while readers and writers see plaintext. Sub-directories are + * Wrap an OPFS directory so every file under it rests sealed by + * `sealer` while readers and writers see plaintext. Sub-directories are * wrapped on the way out, so the whole subtree is covered. * * Hand the result to `filesystemWeb({ preopens: { "/": here }, writable: * true })`. Nothing above needs to know it is there. */ -export function sealedDirectory(inner: OpfsDirectoryHandle, dek: CryptoKey): OpfsDirectoryHandle { +export function sealedDirectory(inner: OpfsDirectoryHandle, sealer: FileSealer): OpfsDirectoryHandle { const dir: OpfsDirectoryHandle = { kind: "directory", name: inner.name, getDirectoryHandle: async (name, opts) => - sealedDirectory(await inner.getDirectoryHandle(name, opts), dek), - getFileHandle: async (name, opts) => sealedFile(await inner.getFileHandle(name, opts), dek), + sealedDirectory(await inner.getDirectoryHandle(name, opts), sealer), + getFileHandle: async (name, opts) => sealedFile(await inner.getFileHandle(name, opts), sealer), removeEntry: (name, opts) => inner.removeEntry(name, opts), entries: async function* () { for await (const [name, h] of inner.entries()) { - yield [name, h.kind === "directory" ? sealedDirectory(h, dek) : sealedFile(h, dek)] as [ + yield [name, h.kind === "directory" ? sealedDirectory(h, sealer) : sealedFile(h, sealer)] as [ string, OpfsDirectoryHandle | OpfsFileHandle, ]; @@ -311,7 +316,7 @@ export function sealedDirectory(inner: OpfsDirectoryHandle, dek: CryptoKey): Opf * ```ts * const ns = openNamespace(id); * const fragment = filesystemWeb({ - * preopens: sealedPreopens(dek, { "/": await ns.directory() }), + * preopens: sealedPreopens(seal.sealed, { "/": await ns.directory() }), * writable: true, * }); * ``` @@ -324,10 +329,10 @@ export function sealedDirectory(inner: OpfsDirectoryHandle, dek: CryptoKey): Opf * workaround. */ export function sealedPreopens( - dek: CryptoKey, + sealer: FileSealer, preopens: Record, ): Record { return Object.fromEntries( - Object.entries(preopens).map(([guestName, handle]) => [guestName, sealedDirectory(handle, dek)]), + Object.entries(preopens).map(([guestName, handle]) => [guestName, sealedDirectory(handle, sealer)]), ); } diff --git a/runtime/device-store/worker.ts b/runtime/device-store/worker.ts index 73626123..d76f976d 100644 --- a/runtime/device-store/worker.ts +++ b/runtime/device-store/worker.ts @@ -23,7 +23,7 @@ // * THE UNSEALED DEK. "Unsealed while the app is open anywhere" is // exactly this global's lifetime; there is no extra machinery and // there is deliberately no way to read the key back out (every -// handle seal.ts hands over is non-extractable). +// DEK is parked inside the seal component and never crosses). // * one engine instance, mounted on the sealed state root, and the // checkpoint cadence over it. // @@ -78,36 +78,40 @@ import { normalizeOrigin, } from "../store-egress.ts"; import { getSigningKey, makeSigner, type Signer } from "../keystore.ts"; -// THE SAME MODULE INSTANCE THE ENGINE'S OWN IMPORTS COME FROM. `newEngine` -// builds the port's fragment with `webcryptoImports()` out of -// `@polymorph/webcrypto-polyengine` (engine.ts:13), and these two statics -// are exports of THAT module — so a handle minted here lands in the same -// class family the port's own imports serve. Reaching for a second copy -// of the package (a different specifier, an unpinned range) would mint -// wrappers the port does not recognize, and the failure would arrive as -// an unhelpful lowering error deep inside a call. The specifier is -// spelled identically to engine.ts's on purpose; demo/deno.json maps it -// once for the whole graph, which is that file's stated reason for -// existing. -import { SigningKey, VerifyingKey } from "@polymorph/webcrypto-polyengine"; +// THE MODULE-IDENTITY CONSTRAINT MOVED WITH THE KEYS. This file no longer +// mints webcrypto wrappers at all: the device identity arrives from the +// seal component ALREADY as `SigningKey`/`VerifyingKey`, because +// seal-component.ts instantiates that component with the same +// `webcryptoImports()` out of the same `@polymorph/webcrypto-polyengine` +// specifier engine.ts:13 uses. That is what makes the handoff to the +// engine's `device-identity` fragment a no-op rather than a conversion — +// one class family, served to both components. demo/deno.json maps the +// specifier once for the whole graph, which is that file's stated reason +// for existing. import { getDevice } from "./index.ts"; -import { DEVICE_ENDPOINT_KEY, DEVICE_IDENTITY_KEY, loadOrMintIdentity } from "./identity-keys.ts"; import { type DeviceNamespace, destroyNamespace, openNamespace } from "./namespace.ts"; import { - createSealedDek, - enablePrf, - enableUntilReseal, - rekeyFromPlatform, - reseal as resealNamespace, + KEY_PASSPHRASE_WRAP, + KEY_PLATFORM_WRAP, + KEY_PRF_WRAP, + type PassphraseWrap, + type PlatformWrap, + type PrfWrap, + SEAL_STORE, SealError, - sealedDelete, - sealedGet, - sealedPut, - sealState, - unsealFromPlatform, - unsealWithPassphrase, - unsealWithPrf, -} from "./seal.ts"; + type SealState, +} from "./seal-records.ts"; +// THE SEAL IS A COMPONENT (runtime/device-seal/). `openSeal` instantiates +// it over THIS device's namespace and hands back the ladder, the sealed +// KV/file surface and the device's signing handles. No DEK crosses back: +// where this file used to hold a `CryptoKey` in `dek`, it holds `seal` +// and asks it to spend the key it parked. +import { + type DeviceSeal, + type IdentityPair, + openSeal, + sealArtifacts, +} from "./seal-component.ts"; import { sealedDirectory } from "./sealed-fs.ts"; import { type DeviceLock, @@ -296,14 +300,84 @@ async function takeLock(): Promise { // `navigator.credentials` is window-only, so no code in // this global can assert a passkey. What arrives here // is the DERIVED KEK HANDLE (`UnsealOptions.prfKek`) — -// non-extractable, validated in seal.ts before use. +// non-extractable, validated by the seal before use. // The worker never sees the raw PRF output and never // persists the handle. device-store/passkey.ts is the // window half; PERSISTENCE.md's "The PRF rung: passkey // unseal" is the record. -/** The unwrapped DEK, or null while sealed. THE WHOLE UNSEALED STATE. */ -let dek: CryptoKey | null = null; +/** + * THE DEVICE SEAL — this device's component instance, and the whole + * unsealed state. + * + * WHAT REPLACED `dek`. This file used to hold the unwrapped DEK in a + * `CryptoKey` variable and hand it to two modules; the export ban in + * demo/scripts/check-invariants.sh (4) was what stood between that + * variable and the platform's own key-export verb. Now the DEK is + * parked INSIDE the component + * (world.wit:13-18) and this global holds only a handle to the thing + * that spends it: `seal.unsealed()` is the old `dek !== null`, + * `seal.forget()` is the old `dek = null`, and `seal.sealed.*` is what + * `sealedPut`/`sealedGet`/`sealedDirectory` used to be given the key + * for. There is no key here to export. + * + * Null until `attach`, which is where the artifacts to instantiate it + * arrive. + */ +let seal: DeviceSeal | null = null; + +/** The seal, or a refusal naming why there is none. Every ceremony below + * goes through this rather than through `seal!`: "no client has attached" + * and "the device is sealed" are different facts and a bare non-null + * assertion would collapse them into a TypeError. */ +function requireSeal(): DeviceSeal { + if (!seal) { + throw new SealError( + "no-rung", + "device-store: the host was never attached (no seal artifacts)", + ); + } + return seal; +} + +/** Whether this device is open — the successor to `dek !== null`, and + * synchronous as that test was (seal-component.ts's `unsealed()` carries + * the reason it can be). */ +const unsealed = (): boolean => seal !== null && seal.unsealed(); + +/** + * WHICH RUNGS THIS DEVICE HAS, answerable BEFORE `attach`. + * + * CONTRACT: the component owns this question (`seal.state()`), and every + * ceremony below asks it that way. `status()` cannot, in one case: it is + * reachable on a raw port before any client has attached (see its own + * note), and `attach` is where the artifacts to instantiate the + * component arrive. Refusing there would turn a defensive path that used + * to answer into a throw. + * + * So the pre-attach arm reads the three record keys directly. It is the + * SAME three existence checks the component makes — `state()` does no + * shape validation either, deliberately, because the picker's question + * is "does a record exist" (component.rs's `state`) — plus the one + * `origin === "user"` rule, cited here rather than re-reasoned. Once a + * client has attached, the component answers and this arm is dead. + */ +async function sealRungs(): Promise { + if (seal) return await seal.state(); + const [p, k, r] = await Promise.all([ + ns.get(SEAL_STORE, KEY_PASSPHRASE_WRAP), + ns.get(SEAL_STORE, KEY_PLATFORM_WRAP), + ns.get(SEAL_STORE, KEY_PRF_WRAP), + ]); + return { + passphrase: p !== undefined, + // `origin` absent means `generated` — a door with no key + // (seal-records.ts's `PassphraseWrap.origin`). + userPassphrase: p !== undefined && p.origin === "user", + untilReseal: k !== undefined, + prf: r !== undefined, + }; +} /** * The engine and how it came up. `resumed` is `true` when @@ -354,7 +428,7 @@ let destroyed = false; * not a race to lose. */ async function unseal(opts: UnsealOptions = {}): Promise { - if (dek) return await status(); + if (unsealed()) return await status(); const record = await getDevice(DEVICE_ID); if (!record) { // The degrade rule's storage half (PERSISTENCE.md, "T0 reload @@ -366,7 +440,7 @@ async function unseal(opts: UnsealOptions = {}): Promise { throw new SealError("no-rung", `device-store: no device ${DEVICE_ID} in the index`); } - const rungs = await sealState(ns); + const rungs = await requireSeal().state(); // `!rungs.prf` IS A TRIPWIRE, not a reachable branch: every path that // writes a PRF wrap starts from a device that already has rungs. But // if a namespace ever DID hold only a PRF wrap, falling into @@ -375,9 +449,9 @@ async function unseal(opts: UnsealOptions = {}): Promise { // `already-sealed` refusal exists to prevent. Climb instead; the worst // a climb can do is refuse. if (!rungs.passphrase && !rungs.untilReseal && !rungs.prf) { - dek = await firstSeal(record.tier, opts); + await firstSeal(record.tier, opts); } else { - dek = await climbRung(record.unsealPolicy, rungs, opts); + await climbRung(record.unsealPolicy, rungs, opts); } // UNSEALING IS ATOMIC: KEY *AND* ENGINE, OR NEITHER. @@ -404,7 +478,10 @@ async function unseal(opts: UnsealOptions = {}): Promise { try { await bringUpEngine(); } catch (e) { - dek = null; + // FORGET rather than merely dropping a reference: the DEK is parked + // inside the component now, so nulling a variable would leave the + // device open. `forget()` is what re-seals it. + await requireSeal().forget(); engine = null; resumed = null; // THE GRANT GOES BACK TOO. `bringUpEngine` arms the grant BEFORE the @@ -434,13 +511,13 @@ async function unseal(opts: UnsealOptions = {}): Promise { * the point of the worker: the key is generated inside this global and * never leaves it. */ -async function firstSeal(tier: string, opts: UnsealOptions): Promise { +async function firstSeal(tier: string, opts: UnsealOptions): Promise { if (opts.passphrase !== undefined) { - const key = await createSealedDek(ns, opts.passphrase); - if (opts.untilReseal) await enableUntilReseal(ns, opts.passphrase); - return key; + await requireSeal().createSealedDek(opts.passphrase); + if (opts.untilReseal) await requireSeal().enableUntilReseal(opts.passphrase); + return; } - if (tier === "t0") return await sealT0(ns); + if (tier === "t0") return await sealT0(); // A T1 device with no rung and no passphrase offered is the legal // intermediate state index.ts's `promoteDevice` documents ("a device // whose row says t1 and which has no wrap yet … the next boot's unseal @@ -472,7 +549,7 @@ async function firstSeal(tier: string, opts: UnsealOptions): Promise * enough to open a T0 device. It is also exactly as strong as what T0 * promises, which is nothing durable. * - * The implementation uses ONLY seal.ts's exported ceremonies rather than + * The implementation uses ONLY the seal's exported ceremonies rather than * duplicating its record layout: `enableUntilReseal` requires a * passphrase rung to re-wrap from, so one is minted from 32 random bytes * and then dropped on the floor. Nothing holds it, nothing persists it, @@ -481,17 +558,16 @@ async function firstSeal(tier: string, opts: UnsealOptions): Promise * able to open a T0 device with a passphrase is not a feature anyone * asked for; if it ever is, that is a promotion.) */ -async function sealT0(namespace: DeviceNamespace): Promise { +async function sealT0(): Promise { const throwaway = Array.from( crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, "0"), ).join(""); // `generated`: nothing kept this passphrase and nothing can reproduce - // it, and the record says so — see seal.ts's `PassphraseWrap.origin` + // it, and the record says so — see seal-records.ts's `PassphraseWrap.origin` // and the reseal ceremony that consults it. - const key = await createSealedDek(namespace, throwaway, "generated"); - await enableUntilReseal(namespace, throwaway); - return key; + await requireSeal().createSealedDek(throwaway, "generated"); + await requireSeal().enableUntilReseal(throwaway); } /** Climb the rung the DEVICE RECORD names — never the one the caller @@ -500,14 +576,14 @@ async function climbRung( policy: string, rungs: { passphrase: boolean; untilReseal: boolean; prf: boolean }, opts: UnsealOptions, -): Promise { +): Promise { // THE PASSKEY POLICY IS TRIED FIRST AND NEVER FALLS TO THE PLATFORM // WRAP. Promotion deleted that wrap precisely so this device asks; if // a stale one somehow survived, using it would open the device without // the ceremony the user chose — the `every-session` arm's // asked-to-be-asked rule, applied to the rung that replaced it. if (policy === "passkey") { - if (opts.prfKek) return await unsealWithPrf(ns, opts.prfKek); + if (opts.prfKek) return await requireSeal().unsealWithPrf(opts.prfKek); // The explicit fallback the design record allows: rungs are ADDITIVE, // so a device switched to passkey unseal on the this-device sheet may // still carry the user's own passphrase, and the picker offers "use @@ -515,9 +591,9 @@ async function climbRung( // not a door — `rungs.passphrase` alone would let one through — so // the caller must have offered a passphrase AND the device must have // a passphrase rung for this to be tried at all; a wrong one refuses - // in seal.ts as it always does. + // in the seal as it always does. if (opts.passphrase !== undefined && rungs.passphrase) { - return await unsealWithPassphrase(ns, opts.passphrase); + return await requireSeal().unsealWithPassphrase(opts.passphrase); } throw new SealError( "no-rung", @@ -528,7 +604,7 @@ async function climbRung( if (opts.passphrase === undefined) { throw new SealError("no-rung", "this device is opened with its passphrase, every session"); } - return await unsealWithPassphrase(ns, opts.passphrase); + return await requireSeal().unsealWithPassphrase(opts.passphrase); } // `until-reseal` and `while-open` both try the persisted wrap first. // For `while-open` that is a degradation to be honest about: this @@ -536,10 +612,11 @@ async function climbRung( // session it was "open for" has ended, and the only remaining doors // are the ones on disk. if (rungs.untilReseal) { - const auto = await unsealFromPlatform(ns); - if (auto) return auto; + if (await requireSeal().unsealFromPlatform()) return; + } + if (opts.passphrase !== undefined) { + return await requireSeal().unsealWithPassphrase(opts.passphrase); } - if (opts.passphrase !== undefined) return await unsealWithPassphrase(ns, opts.passphrase); throw new SealError( "no-rung", "this device's persisted wrap is gone (resealed?); it needs its passphrase", @@ -578,19 +655,19 @@ async function climbRung( * * THE HONEST SENTENCE travels with the choice and belongs in the UI: * `until-reseal` is login convenience, not protection against someone - * holding your profile. See `unseal` above and seal.ts's + * holding your profile. See `unseal` above and the seal's * `enableUntilReseal`. */ async function promote(opts: PromoteOptions): Promise { - if (!dek) throw new SealError("no-rung", "the device is sealed; open it before keeping it"); + if (!unsealed()) throw new SealError("no-rung", "the device is sealed; open it before keeping it"); if (opts.policy === "every-session") { if (opts.passphrase === undefined) { throw new SealError("no-rung", "this rung is the passphrase; the ceremony needs one"); } - await rekeyFromPlatform(ns, opts.passphrase); - await resealNamespace(ns); + await requireSeal().rekeyFromPlatform(opts.passphrase); + await requireSeal().reseal(); } else if (opts.policy === "until-reseal") { - const rungs = await sealState(ns); + const rungs = await requireSeal().state(); if (!rungs.untilReseal) { if (opts.passphrase === undefined) { throw new SealError( @@ -598,7 +675,7 @@ async function promote(opts: PromoteOptions): Promise { "this device's platform wrap is gone (resealed?); re-arming it needs the passphrase", ); } - await enableUntilReseal(ns, opts.passphrase); + await requireSeal().enableUntilReseal(opts.passphrase); } } else if (opts.policy === "passkey") { // The page has already run the enrollment ceremony (passkey.ts) and @@ -612,18 +689,17 @@ async function promote(opts: PromoteOptions): Promise { ); } const { credentialId, transports, rpId, prfInput, hkdfSalt } = opts.prf; - await enablePrf( - ns, + await requireSeal().enablePrf( opts.prf.kek, { credentialId, transports, rpId, prfInput, hkdfSalt }, - { passphrase: opts.passphrase }, + opts.passphrase, ); // THE PLATFORM DOOR SHUTS, for the `every-session` arm's reason // verbatim: a user who chose to be asked — here, asked for their // passkey — must not leave a door standing that skips the question. // `reseal()`'s durable half is exactly that deletion, and the PRF - // wrap just written survives it by design (seal.ts's `reseal`). - await resealNamespace(ns); + // wrap just written survives it by design (world.wit's `reseal`). + await requireSeal().reseal(); } else { // `while-open` is the T0 rung and is not a thing to be promoted TO // (PERSISTENCE.md's ladder offers two rungs at the promotion @@ -675,7 +751,7 @@ async function promote(opts: PromoteOptions): Promise { * in-flight call still holds its own closure until it settles. */ async function reseal(opts: ResealOptions = {}): Promise { - const rungs = await sealState(ns); + const rungs = await requireSeal().state(); // WHOSE PASSPHRASE RUNG IS IT? Not a question the index can answer: // its policy tag says which ceremony to OFFER, and a device may sit on // `until-reseal` and also have the user's own passphrase (that is what @@ -697,7 +773,7 @@ async function reseal(opts: ResealOptions = {}): Promise { "sealing this device means choosing what unseals it: this ceremony needs a passphrase", ); } - await rekeyFromPlatform(ns, opts.passphrase); + await requireSeal().rekeyFromPlatform(opts.passphrase); } // THE FINAL CHECKPOINT, and it is the FALLIBLE HALF, taken first. // @@ -759,8 +835,10 @@ async function reseal(opts: ResealOptions = {}): Promise { // would leave exactly that state uncheckpointed. await syncFlushNow(); if (engine !== null) await checkpoint(); - await resealNamespace(ns); - dek = null; + await requireSeal().reseal(); + // THE PARKED DEK GOES, not just a reference to it: the durable half is + // above, and this is the in-memory half the old `dek = null` was. + await requireSeal().forget(); engine = null; resumed = null; // The timestamp goes too: a sealed device has no engine, and reporting @@ -847,8 +925,8 @@ const STORE_BINDING_KEY = "storage"; /** Read the binding out of the sealed namespace, or undefined if this * device has none. Propagates `SealError "tampered"` — see `readBinding` * callers. */ -async function readBinding(key: CryptoKey): Promise { - const bytes = await sealedGet(ns, key, STORE_BINDING_KEY); +async function readBinding(): Promise { + const bytes = await requireSeal().sealed.get(STORE_BINDING_KEY); if (!bytes) return undefined; return JSON.parse(new TextDecoder().decode(bytes)) as StoreBinding; } @@ -892,14 +970,14 @@ interface OauthRow { obtainedAt: number; } -async function readOauth(key: CryptoKey): Promise { - const bytes = await sealedGet(ns, key, OAUTH_KEY); +async function readOauth(): Promise { + const bytes = await requireSeal().sealed.get(OAUTH_KEY); if (!bytes) return undefined; return JSON.parse(new TextDecoder().decode(bytes)) as OauthRow; } -async function writeOauth(key: CryptoKey, row: OauthRow): Promise { - await sealedPut(ns, key, OAUTH_KEY, new TextEncoder().encode(JSON.stringify(row))); +async function writeOauth(row: OauthRow): Promise { + await requireSeal().sealed.put(OAUTH_KEY, new TextEncoder().encode(JSON.stringify(row))); } /** @@ -932,7 +1010,7 @@ async function applyBinding(b: StoreBinding): Promise { if (b.kind === "gdrive") { const origin = normalizeOrigin(b.apiBase); if (origin === null) return null; - const row = dek ? await readOauth(dek) : undefined; + const row = unsealed() ? await readOauth() : undefined; if (!row) { // No consent rests: leave the grant EMPTY. The device knows where // its store is and has no authority to reach it, which is the @@ -1185,7 +1263,7 @@ async function oauthStart(spec: OauthStartSpec): Promise { // A ceremony that succeeded on a sealed device would end holding // tokens with nowhere sealed to put them, so it refuses at the front // rather than at the seal. - if (!dek) { + if (!unsealed()) { throw new SealError("no-rung", "the device is sealed; open it before connecting an account"); } const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); @@ -1221,7 +1299,7 @@ async function oauthStart(spec: OauthStartSpec): Promise { * is still `bindStore`'s job: consent and commitment stay two acts. */ async function oauthComplete(code: string, state: string): Promise { - if (!dek) { + if (!unsealed()) { throw new SealError("no-rung", "the device is sealed; open it before connecting an account"); } const pending = pendingCeremony; @@ -1276,7 +1354,7 @@ async function oauthComplete(code: string, state: string): Promise if (parsed.refresh_token) row.refresh = parsed.refresh_token; if (spec.clientSecret !== undefined) row.clientSecret = spec.clientSecret; if (spec.tokenUrl !== undefined) row.tokenUrl = spec.tokenUrl; - await writeOauth(dek, row); + await writeOauth(row); // The code was one-shot and is now spent; the verifier has nothing // left to be bound to. pendingCeremony = null; @@ -1305,10 +1383,17 @@ async function oauthComplete(code: string, state: string): Promise * actually issued one. */ function onTokenRefreshed(token: string, refreshToken?: string): void { - const key = dek; - if (!key) return; + if (seal === null) return; + // THE GENERATION THIS REFRESH BELONGS TO, snapshotted SYNCHRONOUSLY + // — this function is called in the middle of a 401→refresh→retry and + // cannot await (see the header). A reseal, or a reseal and a fresh + // unseal, between here and the write below makes this row the wrong + // device-session's, and the counter is what says so: `unsealed()` + // alone could not, since it reads `true` at both ends of such a pair. + const generation = seal.epoch(); void (async () => { - const row = await readOauth(key); + if (!unsealed()) return; + const row = await readOauth(); if (!row) return; row.access = token; if (refreshToken) row.refresh = refreshToken; @@ -1321,8 +1406,8 @@ function onTokenRefreshed(token: string, refreshToken?: string): void { // land on the other side of the delete. Nothing is lost by // skipping: the row is being deleted with the namespace, and the // token it describes belongs to a device that no longer exists. - if (destroyed) return; - await writeOauth(key, row); + if (destroyed || !unsealed() || requireSeal().epoch() !== generation) return; + await writeOauth(row); })().catch(() => {}); } @@ -1343,10 +1428,10 @@ function onTokenRefreshed(token: string, refreshToken?: string): void { * grant: a bearer must not outlive the consent it came from. */ async function forgetOauth(): Promise { - if (!dek) { + if (!unsealed()) { throw new SealError("no-rung", "the device is sealed; open it before disconnecting an account"); } - const row = await readOauth(dek); + const row = await readOauth(); if (row) { const revokeUrl = row.tokenUrl ? new URL("/revoke", row.tokenUrl).toString() @@ -1359,7 +1444,7 @@ async function forgetOauth(): Promise { }); } catch { /* best-effort: see the doc comment */ } } - await sealedDelete(ns, OAUTH_KEY); + await requireSeal().sealed.delete(OAUTH_KEY); clearGrant(); return await status(); } @@ -1381,10 +1466,10 @@ async function bindStore(binding: StoreBinding): Promise { // Sealed means no DEK to seal the binding under and no engine to // re-point; the file's idiom for "open it first" is a `SealError // "no-rung"`, and clients already branch on that code. - if (!dek || !engine) { + if (!unsealed() || !engine) { throw new SealError("no-rung", "the device is sealed; open it before binding storage"); } - const stored = await settleBinding(binding, dek); + const stored = await settleBinding(binding); // A THROW FROM HERE LEAVES THE BINDING SEALED AND THE GRANT ARMED // while the live instance still has no addressing — self-consistent // rather than half-open (the seams refuse or the engine does, and @@ -1444,8 +1529,8 @@ function storeConfigOf(b: StoreBinding): StoreConfig { * but not the disk would come back unbound at the next unseal, which is * the confusing direction. */ -async function settleBinding(binding: StoreBinding, key: CryptoKey): Promise { - if (binding?.kind === "gdrive") return await settleGdrive(binding, key); +async function settleBinding(binding: StoreBinding): Promise { + if (binding?.kind === "gdrive") return await settleGdrive(binding); if (binding?.kind !== "s3") { // The two arms this host binds are S3 and Google Drive. DROPBOX is // still parked for the worker and the reason is unchanged @@ -1501,7 +1586,7 @@ async function settleBinding(binding: StoreBinding, key: CryptoKey): Promise, - key: CryptoKey, ): Promise { if (binding.root.trim() === "" || binding.clientId.trim() === "") { throw new StoreError("bad-destination", "a Drive binding needs a root folder and a client id"); @@ -1544,7 +1628,7 @@ async function settleGdrive( `the Drive API base is not a usable origin: ${binding.apiBase}`, ); } - const row = await readOauth(key); + const row = await readOauth(); if (!row) { throw new StoreError( "no-credential", @@ -1580,7 +1664,7 @@ async function settleGdrive( clientId: binding.clientId, space: binding.space, }; - await sealedPut(ns, key, STORE_BINDING_KEY, new TextEncoder().encode(JSON.stringify(stored))); + await requireSeal().sealed.put(STORE_BINDING_KEY, new TextEncoder().encode(JSON.stringify(stored))); await applyBinding(stored); return stored; } @@ -1606,10 +1690,10 @@ async function settleGdrive( * ceremony a user asks for by name. */ async function unbindStore(): Promise { - if (!dek) { + if (!unsealed()) { throw new SealError("no-rung", "the device is sealed; open it before unbinding storage"); } - await sealedDelete(ns, STORE_BINDING_KEY); + await requireSeal().sealed.delete(STORE_BINDING_KEY); clearGrant(); return await status(); } @@ -1630,6 +1714,31 @@ async function fetchArtifacts(spec: AttachSpec["artifacts"]) { return { envelope, bytes: new Uint8Array(bytes) }; } +/** + * THE SEAL COMPONENT'S ARTIFACTS, FETCHED BESIDE THE ENGINE'S. + * + * Same directory, by convention and by construction: both are copied + * into the served tree by the same build recipe, so the seal's plan and + * wasm are the engine's URLs with the filename swapped. Deriving them + * rather than adding two more fields to `AttachSpec` keeps the wire + * unchanged and makes it impossible for a client to point the seal and + * the engine at different builds. + */ +async function fetchSealArtifacts(spec: AttachSpec["artifacts"]) { + const beside = (name: string) => new URL(name, new URL(spec.wasmUrl, self.location.href)); + const [envelope, bytes] = await Promise.all([ + fetch(beside("device-seal.plan.json")).then((r) => { + if (!r.ok) throw new Error(`device-seal plan: HTTP ${r.status}`); + return r.text(); + }), + fetch(beside("device-seal.component.wasm")).then((r) => { + if (!r.ok) throw new Error(`device-seal wasm: HTTP ${r.status}`); + return r.arrayBuffer(); + }), + ]); + return sealArtifacts(envelope, new Uint8Array(bytes)); +} + // --- the device identity (platform posture) --------------------------------- // // THE POSTURE THE DESIGN ALWAYS WANTED (PERSISTENCE.md, "Device signing @@ -1653,7 +1762,7 @@ async function fetchArtifacts(spec: AttachSpec["artifacts"]) { * The device's key pair, loaded ONCE per worker global. * * `loadOrMintIdentity` is already race-free and validate-on-load - * (identity-keys.ts), so the caching here is about not paying an + * (the component's add-if-absent slot), so the caching here is about not paying an * IndexedDB round trip on every `deviceKeyPair()` call — the engine asks * at least once per instantiation and the answer cannot change while * this global lives. @@ -1663,24 +1772,23 @@ async function fetchArtifacts(spec: AttachSpec["artifacts"]) { * which for a device host means "this device never opens again until you * close every tab". */ -let identityPair: Promise | undefined; +let identityPair: Promise | undefined; /** * The device's TRANSPORT key pair, cached on the same terms and for the * same reasons as the signing one above — and a genuinely separate pair - * (identity-keys.ts's `DEVICE_ENDPOINT_KEY`, engine.wit's + * (the `device-endpoint` slot, engine.wit's * `endpoint-key-pair`): iroh's endpoint id is this key's public half, * and no key crosses between keyhive's signatures and iroh's handshake. */ -let endpointPair: Promise | undefined; +let endpointPair: Promise | undefined; /** Where the fresh-init agent id is recorded, in the unsealed `meta` * store beside the lease and the boot counter. */ const AGENT_KEY = "agent"; -function devicePair(): Promise { - identityPair ??= loadOrMintIdentity(ns, DEVICE_IDENTITY_KEY) - .then((r) => r.pair) +function devicePair(): Promise { + identityPair ??= requireSeal().identity.loadOrMint("device-signing") .catch((e) => { identityPair = undefined; throw e; @@ -1688,9 +1796,8 @@ function devicePair(): Promise { return identityPair; } -function endpointKey(): Promise { - endpointPair ??= loadOrMintIdentity(ns, DEVICE_ENDPOINT_KEY) - .then((r) => r.pair) +function endpointKey(): Promise { + endpointPair ??= requireSeal().identity.loadOrMint("device-endpoint") .catch((e) => { endpointPair = undefined; throw e; @@ -1701,41 +1808,28 @@ function endpointKey(): Promise { /** * Build the `device-identity` fragment for ONE engine instance. * - * FRESH PER INSTANCE, and that is not incidental: the port's resource - * classes carry per-instance registry identity (engine.ts's module - * header, the polymorph-iroh host-deltic finding), so a `SigningKey` - * wrapper minted for one instance must not be handed to another. What is - * cached across instances is the `CryptoKeyPair` — plain platform - * handles, which belong to no registry — and the wrappers are minted at - * the moment the engine asks. - * - * `fromCryptoKey` is the merged webcrypto#392 injection seam: it - * launders the key, checks the type, algorithm and usages, and mints a - * wrapper under the port's private token. The non-extractability rides - * along untouched — the port never sees material either. + * THE PAIR ARRIVES AS THE PORT'S OWN WRAPPERS AND IS PASSED STRAIGHT + * THROUGH — no `fromCryptoKey` here any more, and that deletion is the + * point of the seam rather than a shortcut (world.wit:310-317). The seal + * component and the engine are served by the SAME host webcrypto module + * (seal-component.ts and engine.ts spell one specifier), so what + * `identity.load-or-mint` hands back is already a `SigningKey` of the + * class the engine's own imports serve. Laundering it back through a + * `CryptoKey` and re-minting would be a conversion between a class and + * itself. + * + * The non-extractability rides along untouched: the private half was + * minted `extractable: false` inside the component and nothing on this + * path can read material either way. */ function deviceIdentityFragment(): DeviceIdentityFragment { return { - deviceKeyPair: async () => { - const pair = await devicePair(); - return [ - SigningKey.fromCryptoKey(pair.privateKey), - VerifyingKey.fromCryptoKey(pair.publicKey), - ]; - }, + deviceKeyPair: async () => await devicePair(), // THE ENDPOINT ID SURVIVES THE RELOAD, which is the point: this // device's iroh address is derived from a key that lives in the // device namespace, so a peer that recorded the id can still dial it - // after both sides have been closed and reopened. Fresh wrappers per - // instance for the registry-identity reason in this function's - // header; the underlying `CryptoKeyPair` is the cached one. - endpointKeyPair: async () => { - const pair = await endpointKey(); - return [ - SigningKey.fromCryptoKey(pair.privateKey), - VerifyingKey.fromCryptoKey(pair.publicKey), - ]; - }, + // after both sides have been closed and reopened. + endpointKeyPair: async () => await endpointKey(), }; } @@ -1766,7 +1860,7 @@ async function bringUpEngine(restore?: RestorePlan): Promise { if (engine) return; requireJspi(); if (!attached) throw new Error("device-store: the host was never attached (no engine artifacts)"); - if (!dek) throw new SealError("no-rung", "the device is sealed"); + if (!unsealed()) throw new SealError("no-rung", "the device is sealed"); const dir = await ns.directory(); // THE BINDING IS READ AND APPLIED BEFORE THE ENGINE EXISTS, which is @@ -1781,7 +1875,7 @@ async function bringUpEngine(restore?: RestorePlan): Promise { // is right above us and will put the device back to sealed rather than // leave it half open, and a binding that has been altered underneath // the DEK is a finding worth surfacing at the ceremony that touched it. - const binding = await readBinding(dek); + const binding = await readBinding(); if (binding) await applyBinding(binding); // The cast is the one engine.ts, sealed-fs.ts and the spike all // document: the DOM's `FileSystemDirectoryHandle` does not @@ -1789,7 +1883,7 @@ async function bringUpEngine(restore?: RestorePlan): Promise { // parameter form, `Uint8Array` vs `ArrayBuffer`) // although the runtime shapes match exactly. // deno-lint-ignore no-explicit-any - const sealed = sealedDirectory(dir as any, dek); + const sealed = sealedDirectory(dir as any, requireSeal().sealed); const artifacts = await fetchArtifacts(attached.artifacts); const e = await newEngine( attached.label ?? `device-${DEVICE_ID.slice(0, 8)}`, @@ -1992,20 +2086,20 @@ async function restorePrepare(opts: UnsealOptions = {}): Promise { ); } await refuseUnlessFresh(); - if (!dek) { + if (!unsealed()) { const record = await getDevice(DEVICE_ID); if (!record) { throw new SealError("no-rung", `device-store: no device ${DEVICE_ID} in the index`); } - const rungs = await sealState(ns); + const rungs = await requireSeal().state(); // A namespace with rungs has been sealed before, which means a DEK // was minted for it — and `refuseUnlessFresh` has already established // that no ENGINE state rests under it. Climbing rather than minting // a second one is `unseal`'s rule and its reason (a second DEK // silently orphans everything sealed under the first). - dek = (!rungs.passphrase && !rungs.untilReseal && !rungs.prf) - ? await firstSeal(record.tier, opts) - : await climbRung(record.unsealPolicy, rungs, opts); + await ((!rungs.passphrase && !rungs.untilReseal && !rungs.prf) + ? firstSeal(record.tier, opts) + : climbRung(record.unsealPolicy, rungs, opts)); } return await status(); } @@ -2055,16 +2149,17 @@ async function restoreCeremony(spec: RestoreSpec): Promise { ); } await refuseUnlessFresh(); - if (!dek) await restorePrepare(spec.unseal ?? {}); - const key = dek; - if (!key) throw new SealError("no-rung", "the device is sealed; there is nothing to restore into"); + if (!unsealed()) await restorePrepare(spec.unseal ?? {}); + if (!unsealed()) { + throw new SealError("no-rung", "the device is sealed; there is nothing to restore into"); + } const kit = spec.kit; try { // 1. THE DESTINATION, on `bindStore`'s terms and before anything is // fetched. A missing escrow or a mismatched access key is a // refusal HERE rather than a provider 403 in the middle of a // ceremony that has already minted half a device. - const binding = await settleBinding(spec.binding, key); + const binding = await settleBinding(spec.binding); // 2-3. The engine, and the guest's restore inside it. try { await bringUpEngine({ binding, kit, deviceName: spec.deviceName }); @@ -2075,7 +2170,7 @@ async function restoreCeremony(spec: RestoreSpec): Promise { // stays sealed in the namespace (the user entered it correctly and // a retry should not re-ask), but the grant goes: armed seams with // no engine are authority with nothing to authorize. - dek = null; + await requireSeal().forget(); engine = null; resumed = null; clearGrant(); @@ -2561,13 +2656,15 @@ function hexOf(bytes: Uint8Array): string { * * a client bucket op is in flight — defer, see `clientBucketOps`. */ async function syncMayRun(): Promise { - if (destroyed || dek === null || engine === null) return null; + if (destroyed || !unsealed() || engine === null) return null; if (clientBucketOps > 0) return null; const live = engine; - const key = dek; + // The generation this cycle belongs to — see `onTokenRefreshed` for + // why a bool cannot stand in for the old `dek` identity comparison. + const generation = requireSeal().epoch(); let binding: StoreBinding | undefined; try { - binding = await readBinding(key); + binding = await readBinding(); } catch { // A binding that will not open is `unseal`'s and `status()`'s // finding to report, not a background timer's to raise: those two @@ -2578,7 +2675,9 @@ async function syncMayRun(): Promise { if (!binding) return null; // The awaits above are suspension points; re-check that the device did // not seal underneath them. - if (destroyed || dek !== key || engine !== live) return null; + if (destroyed || !unsealed() || requireSeal().epoch() !== generation || engine !== live) { + return null; + } return live; } @@ -2658,7 +2757,7 @@ async function pullUsDoc( let succeeded = 0; let failure: unknown | null = null; for (const sib of siblings) { - if (engine !== live || dek === null || destroyed) break; + if (engine !== live || !unsealed() || destroyed) break; attempted++; try { await live.driver.bucketPull(US_DOC, sib.agentId, undefined); @@ -2751,7 +2850,7 @@ async function flushCycle(): Promise { failure ??= e; } for (const part of parts) { - if (engine !== live || dek === null || destroyed) break; + if (engine !== live || !unsealed() || destroyed) break; try { await live.driver.bucketFlush(part.id); } catch (e) { @@ -2824,7 +2923,7 @@ async function pullCycle(): Promise { const parts = await syncScope(live); for (const part of parts ?? []) { for (const sib of siblings) { - if (engine !== live || dek === null || destroyed) break; + if (engine !== live || !unsealed() || destroyed) break; attempted++; try { // `pickup` is the LINK tier's standing capability and this is an @@ -2855,7 +2954,7 @@ async function pullCycle(): Promise { * already armed sooner. */ function armFlush(delay: number, fromMutation: boolean): void { - if (dek === null || destroyed) return; + if (!unsealed() || destroyed) return; if (!fromMutation && flushTimer !== undefined) return; const now = Date.now(); let due = now + delay; @@ -2882,7 +2981,7 @@ function armFlush(delay: number, fromMutation: boolean): void { } function armPull(delay: number): void { - if (dek === null || destroyed) return; + if (!unsealed() || destroyed) return; if (pullTimer !== undefined) clearTimeout(pullTimer); pullTimer = setTimeout(() => { pullTimer = undefined; @@ -2908,7 +3007,7 @@ function scheduleFlush(): void { * already-open device re-arm one timer, they do not start two loops. */ function startSyncSchedule(): void { - if (dek === null || destroyed) return; + if (!unsealed() || destroyed) return; armPull(0); } @@ -2932,7 +3031,7 @@ function startSyncSchedule(): void { * time — so 45 s later is both safe and sufficient. */ function rearmSyncSchedule(): void { - if (dek === null || destroyed) return; + if (!unsealed() || destroyed) return; armPull(PULL_INTERVAL_MS); } @@ -2993,7 +3092,7 @@ function syncFlushNow(): Promise { /** `status()`'s sync half. Null while sealed or unbound, with the * cannot-know/has-no-opinion split rpc.ts documents. */ function syncStatusOf(binding: StoreBinding | null): SyncStatus | null { - if (dek === null || binding === null) return null; + if (!unsealed() || binding === null) return null; return { lastFlush, lastPull, @@ -3031,16 +3130,16 @@ async function status(): Promise { `there is nothing to report on, and nothing here will recreate it`, ); } - const rungs = await sealState(ns); + const rungs = await sealRungs(); const policy = record?.unsealPolicy ?? "every-session"; // Read once, reported below: `null` while sealed is UNREADABLE, not // absent (see the field's own note). - const gdriveRow = dek === null ? undefined : await readOauth(dek); + const gdriveRow = !unsealed() ? undefined : await readOauth(); // Read once and used TWICE below — by `storage` and by `sync`, whose // null arms are the same two facts (sealed, or nothing bound). Two // reads could straddle a bind and report a destination beside a // "nothing is bound" sync record. - const binding = dek === null ? null : ((await readBinding(dek)) ?? null); + const binding = !unsealed() ? null : ((await readBinding()) ?? null); return { deviceId: DEVICE_ID, tier: record?.tier ?? "t0", @@ -3052,7 +3151,7 @@ async function status(): Promise { // engine the same question from the other side. agentId: (await ns.get("meta", AGENT_KEY)) ?? null, policy, - sealed: dek === null, + sealed: !unsealed(), rungs, // What the picker needs in order to decide whether to render a // passphrase field: `every-session` always, and any other policy @@ -3061,7 +3160,7 @@ async function status(): Promise { // `unseal()` needs is that ceremony, not a passphrase. The picker // learns which to offer from `policy`; `rungs` tells it what else it // may offer beside it. - needsPassphrase: dek === null && + needsPassphrase: !unsealed() && (policy === "every-session" || (!rungs.untilReseal && !rungs.prf)), resumed, lastCheckpoint, @@ -3155,6 +3254,14 @@ async function callHost(method: string, args: unknown[]): Promise { // is a worse outcome than ignoring a redundant argument. They are // the same bytes in every real deployment. attached ??= spec; + // THE SEAL IS INSTANTIATED ONCE PER WORKER, HERE, because this is + // where the artifacts arrive — and BEFORE `takeLock()`, so a + // device whose seal cannot be built never starts a lease. Second + // and later attaches reuse the instance for `attached ??=`'s + // reason: re-pointing a live host at different bytes is worse than + // ignoring a redundant argument, and re-instantiating would drop a + // parked DEK on the floor and silently re-seal an open device. + seal ??= await openSeal(ns, await fetchSealArtifacts(spec.artifacts)); await takeLock(); return await status(); } @@ -3297,7 +3404,12 @@ async function callHost(method: string, args: unknown[]): Promise { // until it settles. The identity promise goes too; its handles // live in the `identity` store, which is one of the things being // deleted. - dek = null; + // THE WHOLE COMPONENT GOES, not just the parked DEK: it closed + // over a namespace that is about to stop existing, so keeping the + // instance would keep a live handle onto deleted storage. Dropping + // it is what dropping the DEK handle used to be, one level up. + await seal?.forget(); + seal = null; engine = null; resumed = null; lastCheckpoint = null; diff --git a/runtime/justfile b/runtime/justfile index 60cbfdb7..8cd7ca1c 100644 --- a/runtime/justfile +++ b/runtime/justfile @@ -38,6 +38,13 @@ check: artifacts: cd ../demo && just translate +# The device seal component's artifacts, on the demo-delegation pattern +# `artifacts` above uses and for the same reason: the matrix must measure +# the SAME bytes the component's own gate built, so the build is +# delegated to that directory rather than reproduced here. +seal-artifacts: + cd device-seal && just build + # Bundle the probe page AND the device host for the browser, and # assemble serve/. # @@ -59,7 +66,7 @@ artifacts: # pairing-engine.ts takes `hex`/`unhex` from engine.ts as VALUES, not # types, so the whole import record comes with them. The page still # instantiates nothing; it just carries the graph. -build: check artifacts +build: check artifacts seal-artifacts mkdir -p {{DEVSTORE}}/serve cd {{DEVSTORE}} && deno bundle --config deno.json --platform browser \ --external node-datachannel --external "node-datachannel/polyfill" --external werift \ @@ -78,6 +85,12 @@ build: check artifacts cp {{DEVSTORE}}/probe.html {{DEVSTORE}}/serve/ cp ../demo/build/engine.plan.json {{DEVSTORE}}/serve/engine.plan.json cp ../engine/target/composed.wasm {{DEVSTORE}}/serve/engine.component.wasm + # THE SEAL COMPONENT, BESIDE THE ENGINE'S BYTES — which is not + # incidental: worker.ts derives these two URLs from the engine's + # `wasmUrl` (its `fetchSealArtifacts`), so "same directory" is the + # wire contract rather than a convenience of this recipe. + cp device-seal/build/device-seal.component.wasm {{DEVSTORE}}/serve/device-seal.component.wasm + cp device-seal/build/device-seal.plan.json {{DEVSTORE}}/serve/device-seal.plan.json # The browser Playwright needs. PROBE-THEN-INSTALL (demo/justfile's # `e2e-deps`, verbatim reasoning): a launch that already works is left diff --git a/runtime/tests/devstore/fixtures/legacy-seal-v1.json b/runtime/tests/devstore/fixtures/legacy-seal-v1.json new file mode 100644 index 00000000..4ce7a647 --- /dev/null +++ b/runtime/tests/devstore/fixtures/legacy-seal-v1.json @@ -0,0 +1,56 @@ +{ + "//": [ + "THE ON-DISK COMPATIBILITY FIXTURE — a device sealed by the", + "PRE-COMPONENT runtime/device-store/seal.ts, captured once in a real", + "Chromium so the port can be proved not to have moved a byte.", + "", + "Captured by the temporary `legacy-capture` probe op (page.ts), which", + "was removed immediately afterwards. The permanent assertion is the", + "`legacy-unseal` matrix row: it loads these records verbatim into a", + "fresh device namespace, opens the device through the COMPONENT, and", + "checks that both plaintexts come back and that a wrong passphrase is", + "still refused `wrong-passphrase`.", + "", + "THE PASSPHRASE IS `PASS` IN page.ts — the labelled synthetic test", + "value \"correct-horse-battery-staple-TEST\". Nothing here is, or", + "resembles, real key material: the DEK under these wraps was minted", + "for this fixture and seals nothing but the two test plaintexts named", + "below. Byte fields are base64.", + "", + "FIELDS:", + " passphraseWrap the `seal` store's `wrap:passphrase` record", + " (seal.ts `PassphraseWrap`): PBKDF2-SHA-256", + " parameters, the 16-byte salt, and the DEK", + " AES-KW-wrapped under the derived KEK. `origin:", + " user` — a person chose the passphrase.", + " sealedValue the `sealed` store's record at key `fixture-key`", + " (seal.ts `SealedValue`): AES-GCM under the DEK with", + " the key string as additional data. Opens to", + " `kvPlaintext`.", + " sealedFile the RAW OPFS bytes of a file written through", + " sealed-fs.ts's directory proxy — PMSEALv1 magic, a", + " 12-byte IV, then ciphertext and GCM tag. Opens to", + " `filePlaintext`.", + " kvPlaintext / what the two sealed artifacts must open back to;", + " filePlaintext page.ts spells them as FIXTURE_KV_PLAINTEXT and", + " FIXTURE_FILE_PLAINTEXT." + ], + "capturedAt": "2026-09-05", + "passphraseWrap": { + "v": 1, + "kdf": "PBKDF2-SHA-256", + "iterations": 600000, + "salt": "uVwEK1BLxuiw3x4OML06JQ==", + "wrapped": "lxVfORom/ztmqhXqldBa0HZYNoxxntUxC+9wbDaQjHaIWrQeSmyYPg==", + "origin": "user" + }, + "sealedKey": "fixture-key", + "sealedValue": { + "v": 1, + "iv": "ZLsUnV3rhiOF6OAY", + "ct": "SEzNKE0XHR9Y6N13x//6r08bc0WevcTe6DFwtrrqSf3dW/0mzfcsxSWeYVE=" + }, + "sealedFile": "UE1TRUFMdjFqgqbGrOq4MaluypCRtREZClxJTNY016i+U6nlAtkLQkqp55unY/0/iIwC3Q9ps01tB8BCVYfgbT34KbrmScqy", + "kvPlaintext": "the-legacy-sealed-value-TEST", + "filePlaintext": "legacy checkpoint plaintext TEST end" +} diff --git a/runtime/tests/devstore/page.ts b/runtime/tests/devstore/page.ts index e18201ea..f802976d 100644 --- a/runtime/tests/devstore/page.ts +++ b/runtime/tests/devstore/page.ts @@ -57,14 +57,11 @@ import { clearAnchor, connectDevice, createDevice, - createSealedDek, type DeviceConnection, - DEVICE_IDENTITY_KEY, deviceLockIsHeld, type DeviceLock, type DeviceNamespace, destroyNamespace, - enableUntilReseal, ensureDevice, getAnchor, getDevice, @@ -73,32 +70,22 @@ import { LEASE_STALE_MS, leaseIsStale, listDevices, - loadIdentity, - loadOrMintIdentity, namespaceExists, newDeviceId, nsDbName, type Posture, openNamespace, - persistIdentity, promoteDevice, readLease, - rekeyPassphrase, removeDevice, - reseal, - sealedGet, sealedPreopens, - sealedPut, SealError, - sealState, setAnchor, startLease, sweepT0, touchDevice, touchLease, type UnsealPolicy, - unsealFromPlatform, - unsealWithPassphrase, } from "../../device-store/mod.ts"; // THE PRF RUNG'S WINDOW HALF (PERSISTENCE.md, "The PRF rung: passkey // unseal"). `passkey.ts` is imported ONLY here, never by worker.ts — @@ -106,14 +93,69 @@ import { // governing note repeats the module's own: this is the split that // keeps the WebAuthn ceremony on the page. import { assertPasskey, enrollPasskey, prfCapability } from "../../device-store/passkey.ts"; -import { getPrfEnrollment } from "../../device-store/seal.ts"; +import { getPrfEnrollment } from "../../device-store/seal-records.ts"; +// THE SEAL AS A COMPONENT. Every row that used to call seal.ts's +// ceremonies directly now opens one over its own namespace — which is +// what the worker does, so these rows measure the same code path the +// host does rather than a page-only shortcut. The page already carries +// the whole engine graph (see this file's header), so `instantiate`, the +// wasi batteries and the webcrypto port are all in hand. +import { + type DeviceSeal, + openSeal, + sealArtifacts, +} from "../../device-store/seal-component.ts"; +// The port's own resource classes, for `pairOf` below — the SAME module +// the seal component's imports come from, which is what makes a pair it +// minted readable here at all. +import type { SigningKey, VerifyingKey } from "@polymorph/webcrypto-polyengine"; // --- obviously-synthetic test values --------------------------------------- const PASS = "correct-horse-battery-staple-TEST"; const PASS_WRONG = "definitely-not-the-passphrase-TEST"; const PASS_NEW = "the-second-passphrase-TEST"; -const IDENTITY_ID = "device-signing"; +/** The two `identity-slot` cases the component serves — the same two + * strings the store was keyed by before the port (identity-keys.ts's + * `DEVICE_IDENTITY_KEY`/`DEVICE_ENDPOINT_KEY`), which is why the slot + * needs no translation table. */ +const IDENTITY_SLOT = "device-signing" as const; +const ENDPOINT_SLOT = "device-endpoint" as const; + +// --- the on-disk compatibility fixture -------------------------------------- +// +// THE PROOF THAT THE FORMAT SURVIVED THE PORT. A device sealed by the +// OLD seal.ts (captured once, into fixtures/legacy-seal-v1.json) must +// open through the COMPONENT. These four constants name what was +// captured and what it must open back to; the `legacy-unseal` row loads +// the fixture into a fresh namespace and asserts exactly that. +// +// The passphrase is `PASS` above — an obviously-synthetic labelled test +// value, and the fixture's header field says so. Everything else the row +// needs travels IN the fixture (the key it rests under, and the two +// plaintexts), so nothing about the capture is restated here where it +// could drift. + +/** + * The fixture as it crosses from the driver (which reads the JSON off + * disk) to this page. Byte fields are base64; the file's own header + * names what each one is. + */ +export interface LegacyFixture { + passphraseWrap: { + v: 1; + kdf: "PBKDF2-SHA-256"; + iterations: number; + salt: string; + wrapped: string; + origin: "user" | "generated"; + }; + sealedKey: string; + sealedValue: { v: 1; iv: string; ct: string }; + sealedFile: string; + kvPlaintext: string; + filePlaintext: string; +} // --- small helpers ---------------------------------------------------------- @@ -153,6 +195,30 @@ function caught( }; } +/** + * Run `body` with `console.warn` captured, and hand back what it said. + * + * A SILENT DISCARD IS THE FAULT, not just a wrong return value: when the + * load path throws away a planted identity entry, the user's account + * depends on someone being able to see WHY they became a new device + * (identity-keys.ts's rule, carried into seal-component.ts's `warnOnce`). + * So the warning is asserted, not assumed. + */ +async function capturingWarnings( + body: () => Promise, +): Promise<{ value: T; warnings: string[] }> { + const warnings: string[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }; + try { + return { value: await body(), warnings }; + } finally { + console.warn = original; + } +} + async function refuses(body: () => Promise): Promise< { refused: boolean; error: ReturnType | null } > { @@ -184,6 +250,13 @@ const hexOf = (b: ArrayBuffer | Uint8Array): string => Array.from(b instanceof Uint8Array ? b : new Uint8Array(b), (x) => x.toString(16).padStart(2, "0")) .join(""); +/** Byte fields travel to and from the JSON fixture as base64 — the one + * place bytes are written down, and they are the labelled synthetic + * fixture's. */ +const b64 = (b: Uint8Array): string => btoa(Array.from(b, (x) => String.fromCharCode(x)).join("")); +const unb64 = (s: string): Uint8Array => + Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + // --- the wasi descriptor surface (the spike's Q2 pattern) ------------------- interface Descriptor03Like { @@ -208,9 +281,9 @@ interface Descriptor03Like { * spike's reason: the DOM handle types do not structurally satisfy the * published interfaces though the runtime shapes match exactly. */ -async function mountSealed(ns: DeviceNamespace, dek: CryptoKey): Promise { +async function mountSealed(ns: DeviceNamespace, seal: DeviceSeal): Promise { const dir = await ns.directory(); - const preopens = sealedPreopens(dek, { + const preopens = sealedPreopens(seal.sealed, { "/": dir as unknown as Parameters[1][string], }); const fragment = filesystemWeb( @@ -275,6 +348,36 @@ async function guestRead(root: Descriptor03Like, path: string): Promise return dec.decode(out); } +// --- the seal component, page-side ----------------------------------------- + +/** + * THE SEAL ARTIFACTS, fetched from the served tree ONCE and shared by + * every row that opens one. + * + * The bytes are cached, the INSTANCE is not: each `sealFor` call + * instantiates a fresh component over its own namespace, which is what + * the worker does per device and what the boundary means — a component + * can spell one device's records and cannot name another. + */ +let sealSource: Promise> | undefined; +function sealArtifactsOnce() { + sealSource ??= (async () => { + const [envelope, bytes] = await Promise.all([ + fetch(new URL("./device-seal.plan.json", location.href)).then((r) => r.text()), + fetch(new URL("./device-seal.component.wasm", location.href)).then((r) => + r.arrayBuffer() + ), + ]); + return sealArtifacts(envelope, new Uint8Array(bytes)); + })(); + return sealSource; +} + +/** Open a seal over one device's namespace — the page's `openSeal`. */ +async function sealFor(ns: DeviceNamespace): Promise { + return await openSeal(ns, await sealArtifactsOnce()); +} + /** Read a device's file straight out of OPFS, with no wrapper anywhere * near it — the "is it actually ciphertext on disk" question. */ async function rawBytes(id: string, path: string): Promise { @@ -351,38 +454,119 @@ const ops: Record Promise> = { passphrase: async () => { const d = await createDevice({ petname: "sealed" }); const ns = openNamespace(d.id); - const dek = await createSealedDek(ns, PASS); - const state = await sealState(ns); + const seal = await sealFor(ns); + await seal.createSealedDek(PASS); + const state = await seal.state(); - // The DEK a caller may hold is not a bearer secret. - const dekExtractable = dek.extractable; + // NO DEK CROSSES AT ALL — the successor to the old row's + // "the handle you may hold is not a bearer secret". The component + // parks it; what the page can observe is only that it IS parked. + const parkedNotHanded = seal.unsealed(); - // Round-trip through the sealed KV to prove the unsealed handle is + // Round-trip through the sealed KV to prove a re-opened seal spends // the SAME key, not merely a key. - await sealedPut(ns, dek, "probe", enc.encode("sealed-kv-payload-TEST")); - const reopened = await unsealWithPassphrase(ns, PASS); - const readBack = dec.decode((await sealedGet(ns, reopened, "probe"))!); + await seal.sealed.put("probe", enc.encode("sealed-kv-payload-TEST")); + const reopened = await sealFor(ns); + await reopened.unsealWithPassphrase(PASS); + const readBack = dec.decode((await reopened.sealed.get("probe"))!); - const wrong = await refuses(() => unsealWithPassphrase(ns, PASS_WRONG)); + const wrong = await refuses(() => sealFor(ns).then((x) => x.unsealWithPassphrase(PASS_WRONG))); const saltBefore = await saltOf(ns); - await rekeyPassphrase(ns, PASS, PASS_NEW); + await seal.rekeyPassphrase(PASS, PASS_NEW); const saltAfter = await saltOf(ns); - const oldRefused = await refuses(() => unsealWithPassphrase(ns, PASS)); - const withNew = await unsealWithPassphrase(ns, PASS_NEW); - const stillReadable = dec.decode((await sealedGet(ns, withNew, "probe"))!); + const oldRefused = await refuses(() => + sealFor(ns).then((x) => x.unsealWithPassphrase(PASS)) + ); + const withNew = await sealFor(ns); + await withNew.unsealWithPassphrase(PASS_NEW); + const stillReadable = dec.decode((await withNew.sealed.get("probe"))!); - const secondMint = await refuses(() => createSealedDek(ns, PASS)); + const secondMint = await refuses(() => seal.createSealedDek(PASS)); + + // A SEALED COMPONENT SPENDS NOTHING: `forget()` drops the parked DEK + // and the sealed surface refuses `no-rung`, which is what dropping + // the old `CryptoKey` handle bought implicitly. + await seal.forget(); + const afterForget = await refuses(() => seal.sealed.get("probe")); return { state, - dekExtractable, + parkedNotHanded, readBack, wrong, saltRotated: saltBefore !== saltAfter && saltBefore.length === 32, oldRefused, stillReadable, secondMint, + forgotten: !seal.unsealed(), + afterForget, + cleanup: await cleanup([d.id]), + }; + }, + + /** + * THE ON-DISK FORMAT SURVIVED THE PORT — the fixture row. + * + * A device sealed by the PRE-COMPONENT seal.ts was captured once, in a + * real browser, into fixtures/legacy-seal-v1.json (the file's own + * header says what each field is). This row loads those records + * VERBATIM into a fresh namespace and opens the device through the + * COMPONENT: the passphrase wrap must yield the DEK, the sealed KV + * record and the sealed FILE must both come back as the plaintexts the + * capture named, and a wrong passphrase must still refuse + * `wrong-passphrase`. + * + * IT FAILS IF EITHER SIDE DRIFTS, which is the point: a change to the + * record shapes (the host's codec) or to the ladder and the PMSEALv1 + * framing (the component's) breaks it, and no reload row would. + */ + "legacy-unseal": async (arg: { fixture: LegacyFixture }) => { + const f = arg.fixture; + const d = await createDevice({ petname: "legacy" }); + const ns = openNamespace(d.id); + + // The records, byte for byte as seal.ts wrote them. + await ns.put("seal", "wrap:passphrase", { + v: f.passphraseWrap.v, + kdf: f.passphraseWrap.kdf, + iterations: f.passphraseWrap.iterations, + salt: unb64(f.passphraseWrap.salt), + wrapped: unb64(f.passphraseWrap.wrapped), + origin: f.passphraseWrap.origin, + }); + await ns.put("sealed", f.sealedKey, { + v: f.sealedValue.v, + iv: unb64(f.sealedValue.iv), + ct: unb64(f.sealedValue.ct), + }); + + const seal = await sealFor(ns); + // The rungs the fixture's records describe, read through the + // component before anything is opened. + const state = await seal.state(); + + await seal.unsealWithPassphrase(PASS); + const opened = seal.unsealed(); + + const kv = await seal.sealed.get(f.sealedKey); + const fileBytes = await seal.sealed.openFile(unb64(f.sealedFile)); + + // A WRONG PASSPHRASE STILL REFUSES, and with the same typed code the + // pre-port ladder used — the bit that says the refusal survived the + // port too, not just the success. + const wrong = await refuses(() => + sealFor(ns).then((x) => x.unsealWithPassphrase(PASS_WRONG)) + ); + + return { + state, + opened, + kv: kv ? dec.decode(kv) : null, + kvMatches: kv !== undefined && dec.decode(kv) === f.kvPlaintext, + file: dec.decode(fileBytes), + fileMatches: dec.decode(fileBytes) === f.filePlaintext, + wrong, cleanup: await cleanup([d.id]), }; }, @@ -391,18 +575,19 @@ const ops: Record Promise> = { kv: async () => { const d = await createDevice({ petname: "kv" }); const ns = openNamespace(d.id); - const dek = await createSealedDek(ns, PASS); + const seal = await sealFor(ns); + await seal.createSealedDek(PASS); const payload = "the-sealed-value-TEST"; - await sealedPut(ns, dek, "blob", enc.encode(payload)); - const round = dec.decode((await sealedGet(ns, dek, "blob"))!); - const absent = await sealedGet(ns, dek, "never-written"); + await seal.sealed.put("blob", enc.encode(payload)); + const round = dec.decode((await seal.sealed.get("blob"))!); + const absent = await seal.sealed.get("never-written"); // FLIP ONE CIPHERTEXT BYTE. GCM's tag is what turns this into a // refusal instead of plausible garbage. const rec = await ns.get<{ v: 1; iv: Uint8Array; ct: Uint8Array }>("sealed", "blob"); rec!.ct[0] ^= 0x01; await ns.put("sealed", "blob", rec); - const tampered = await refuses(() => sealedGet(ns, dek, "blob")); + const tampered = await refuses(() => seal.sealed.get("blob")); return { round, @@ -412,80 +597,130 @@ const ops: Record Promise> = { }; }, - /** The identity library: mint, persist, and the two refusals. - * Returns the device id so the driver can reload and load it back. */ + /** + * THE IDENTITY SLOTS: mint, load-or-mint's idempotence, and the race. + * + * The library moved into the component (world.wit's `identity`), so + * these are asked through a seal rather than of a loose module — and + * the store is keyed by SLOT now (`device-signing`, `device-endpoint`) + * rather than by an arbitrary string, which is what the WIT enum + * means. Returns the device id so the driver can reload and load back. + */ "identity-mint": async () => { const d = await createDevice({ petname: "identity" }); setAnchor(d.id); const ns = openNamespace(d.id); - const { pair, minted } = await loadOrMintIdentity(ns, IDENTITY_ID); - const again = await loadOrMintIdentity(ns, IDENTITY_ID); + const seal = await sealFor(ns); + + const before = await seal.identity.load(IDENTITY_SLOT); + const pair = await seal.identity.loadOrMint(IDENTITY_SLOT); + const again = await seal.identity.loadOrMint(IDENTITY_SLOT); - // The race, exactly as two restored tabs run it. - const raceNs = openNamespace(d.id); + // THE RACE, exactly as two restored tabs run it — two components + // over one namespace, which is closer to the real thing than two + // calls on one instance: each has its own handle table, and the + // add-if-absent slot is the only thing making them agree. + const [ra, rb] = await Promise.all([sealFor(ns), sealFor(ns)]); const [x, y] = await Promise.all([ - loadOrMintIdentity(raceNs, "raced"), - loadOrMintIdentity(raceNs, "raced"), + ra.identity.loadOrMint(ENDPOINT_SLOT), + rb.identity.loadOrMint(ENDPOINT_SLOT), ]); // ONE key, not two: a signature made under one caller's handle // verifies under the other caller's public half. (Comparing handles - // by identity would not prove it — two loads of one stored entry - // are two JS objects.) - const raceSame = await verify(y.pair, await sign(x.pair, "cross-verify-TEST"), "cross-verify-TEST"); - const raceMintedCount = [x.minted, y.minted].filter(Boolean).length; + // by identity would not prove it — two loads of one stored entry are + // two JS objects.) + const raceSame = await verify( + pairOf(y), + await sign(pairOf(x), "cross-verify-TEST"), + "cross-verify-TEST", + ); - // An EXTRACTABLE key is refused at the door. - const loose = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]) as CryptoKeyPair; - const extractableRefused = await refuses(() => persistIdentity(ns, "loose", loose)); + // THE TWO SLOTS ARE GENUINELY DIFFERENT KEYS (engine.wit's ruling: + // no cross-protocol reuse between keyhive's signatures and iroh's + // handshake). + const slotsDiffer = hexOf(await rawPublic(pairOf(pair))) !== + hexOf(await rawPublic(pairOf(x))); return { id: d.id, - minted, - secondCallMinted: again.minted, - extractable: pair.privateKey.extractable, - publicKey: hexOf(await rawPublic(pair)), - signed: await verify(pair, await sign(pair, "identity-probe-TEST"), "identity-probe-TEST"), + mintedOnFirstAsk: before === undefined, + publicKey: hexOf(await rawPublic(pairOf(pair))), + // Load-or-mint is idempotent: the second ask returns the STORED + // pair, not a fresh mint. + secondAskSameKey: hexOf(await rawPublic(pairOf(pair))) === + hexOf(await rawPublic(pairOf(again))), + extractable: pairOf(pair).privateKey.extractable, + signed: await verify( + pairOf(pair), + await sign(pairOf(pair), "identity-probe-TEST"), + "identity-probe-TEST", + ), raceSame, - raceMintedCount, - extractableRefused, + slotsDiffer, }; }, /** After a REAL reload: the handle comes back and still signs, and a - * planted junk entry is discarded rather than handed out. */ + * planted junk entry reads as absent rather than being handed out. */ "identity-after": async (arg: { id: string }) => { const ns = openNamespace(arg.id); - const loaded = await loadIdentity(ns, IDENTITY_ID); + const seal = await sealFor(ns); + const loaded = await seal.identity.load(IDENTITY_SLOT); const signed = loaded - ? await verify(loaded, await sign(loaded, "identity-probe-TEST"), "identity-probe-TEST") + ? await verify( + pairOf(loaded), + await sign(pairOf(loaded), "identity-probe-TEST"), + "identity-probe-TEST", + ) : false; // PLANTED JUNK: anything on this origin can write to IndexedDB, so // the load path treats an entry as untrusted input. Two plants — a // value that is not a key pair at all, and an EXTRACTABLE pair, - // which is the one that would matter. - await ns.put("identity", "junk", { privateKey: "not a key", publicKey: 42 }); - const junk = await loadIdentity(ns, "junk"); - const junkDiscarded = (await ns.get("identity", "junk")) === undefined; - + // which is the one that would matter. VALIDATE-ON-LOAD IS THE + // HOST'S, and `usableIdentity` IN FULL rather than `fromCryptoKey` + // alone, which never looks at `extractable` (world.wit:136-153): a + // stored value that is not a usable key of the right kind reads as + // `none`, and the host says so on the console. + await ns.put("identity", ENDPOINT_SLOT, { privateKey: "not a key", publicKey: 42 }); + const junkSeal = await sealFor(ns); + const junk = await junkSeal.identity.load(ENDPOINT_SLOT); + + // THE PLANT GOES IN THROUGH `ns.put`, BYPASSING THE CODEC — which is + // the point: an attacker with origin access writes the store + // directly, so the entry never passed the check that would have + // refused it on the way in. It must still not be adopted on the way + // out, and the discard must be VISIBLE. const loose = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]) as CryptoKeyPair; - await ns.put("identity", "planted", loose); - const planted = await loadIdentity(ns, "planted"); - const plantedDiscarded = (await ns.get("identity", "planted")) === undefined; - - // And after a discard, load-or-mint gives a REAL key rather than - // looping against the plant. - const after = await loadOrMintIdentity(ns, "planted"); + await ns.put("identity", ENDPOINT_SLOT, loose); + const plantedSeal = await sealFor(ns); + const plantedLoad = await capturingWarnings(() => plantedSeal.identity.load(ENDPOINT_SLOT)); + const planted = plantedLoad.value; + const plantedWarned = plantedLoad.warnings.some((w) => w.includes(ENDPOINT_SLOT)); + + // And after a plant, load-or-mint gives a REAL key rather than + // looping against it — the add-if-absent slot replaces an unusable + // entry in the same transaction. + const after = await plantedSeal.identity.loadOrMint(ENDPOINT_SLOT); + const remintedNonExtractable = pairOf(after).privateKey.extractable === false; + const plantedReplaced = hexOf(await rawPublic(pairOf(after))) !== + hexOf(await rawPublic(loose)); + + // The slot can be forgotten, and then it is genuinely gone. + await plantedSeal.identity.delete(ENDPOINT_SLOT); + const afterDelete = await (await sealFor(ns)).identity.load(ENDPOINT_SLOT); return { - loadedAfterReload: loaded !== null, - publicKey: loaded ? hexOf(await rawPublic(loaded)) : "", + loadedAfterReload: loaded !== undefined, + publicKey: loaded ? hexOf(await rawPublic(pairOf(loaded))) : "", signed, - junkRejected: junk === null, - junkDiscarded, - plantedRejected: planted === null, - plantedDiscarded, - remintedNonExtractable: after.minted && after.pair.privateKey.extractable === false, + junkRejected: junk === undefined, + plantedRejected: planted === undefined, + plantedWarned, + plantedWarning: plantedLoad.warnings.find((w) => w.includes(ENDPOINT_SLOT))?.slice(0, 160) ?? "", + remintedNonExtractable, + plantedReplaced, + deleted: afterDelete === undefined, cleanup: await cleanup([arg.id]), }; }, @@ -494,36 +729,37 @@ const ops: Record Promise> = { "platform-arm": async () => { const d = await createDevice({ petname: "convenient" }); const ns = openNamespace(d.id); - const dek = await createSealedDek(ns, PASS); - await sealedPut(ns, dek, "note", enc.encode("survives-the-reload-TEST")); - await enableUntilReseal(ns, PASS); - return { id: d.id, state: await sealState(ns) }; + const seal = await sealFor(ns); + await seal.createSealedDek(PASS); + await seal.sealed.put("note", enc.encode("survives-the-reload-TEST")); + await seal.enableUntilReseal(PASS); + return { id: d.id, state: await seal.state() }; }, /** After a REAL reload: auto-unseal with NO passphrase. Then reseal, * and prove the passphrase is required again. */ "platform-after": async (arg: { id: string }) => { const ns = openNamespace(arg.id); - const auto = await unsealFromPlatform(ns); - const read = auto ? dec.decode((await sealedGet(ns, auto, "note"))!) : ""; - const autoExtractable = auto?.extractable ?? null; - - await reseal(ns); - const afterReseal = await unsealFromPlatform(ns); - const state = await sealState(ns); + const seal = await sealFor(ns); + const auto = await seal.unsealFromPlatform(); + const read = auto ? dec.decode((await seal.sealed.get("note"))!) : ""; + + await seal.reseal(); + await seal.forget(); + const afterSeal = await sealFor(ns); + const afterReseal = await afterSeal.unsealFromPlatform(); + const state = await afterSeal.state(); // The handle went too, not just the wrap. const handleGone = (await ns.get("seal", "kek:platform")) === undefined; // The passphrase rung is untouched — it is the only thing that can // open the device after a reseal. - const stillOpens = dec.decode( - (await sealedGet(ns, await unsealWithPassphrase(ns, PASS), "note"))!, - ); + await afterSeal.unsealWithPassphrase(PASS); + const stillOpens = dec.decode((await afterSeal.sealed.get("note"))!); return { - autoUnsealed: auto !== null, - autoExtractable, + autoUnsealed: auto, read, - afterResealIsNull: afterReseal === null, + afterResealIsNull: afterReseal === false, state, handleGone, stillOpens, @@ -535,8 +771,9 @@ const ops: Record Promise> = { "fs-write": async (arg: { marker: string }) => { const d = await createDevice({ petname: "filesystem" }); const ns = openNamespace(d.id); - const dek = await createSealedDek(ns, PASS); - const root = await mountSealed(ns, dek); + const seal = await sealFor(ns); + await seal.createSealedDek(PASS); + const root = await mountSealed(ns, seal); const text = `checkpoint plaintext ${arg.marker} end`; await guestWrite(root, "checkpoint.bin", text); // Read it back through a FRESH descriptor before the reload, so a @@ -550,17 +787,22 @@ const ops: Record Promise> = { * and look at what actually rests on disk. */ "fs-after": async (arg: { id: string; marker: string; wrote: string }) => { const ns = openNamespace(arg.id); - const dek = await unsealWithPassphrase(ns, PASS); - const root = await mountSealed(ns, dek); + const seal = await sealFor(ns); + await seal.unsealWithPassphrase(PASS); + const root = await mountSealed(ns, seal); const readBack = await guestRead(root, "checkpoint.bin"); // A DIFFERENT DEK is the "someone else's device key" case, and it - // must fail the way a filesystem fails, not by trapping. - const other = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ - "encrypt", - "decrypt", - ]) as CryptoKey; - const otherRoot = await mountSealed(ns, other); + // must fail the way a filesystem fails, not by trapping. ANOTHER + // DEVICE'S SEAL is how that is spelled now: a second namespace with + // its own minted DEK, asked to open this device's file. (A loose + // AES-GCM key cannot stand in any more — no key crosses the + // boundary, which is the point of the port.) + const otherDevice = await createDevice({ petname: "another-device" }); + const otherNs = openNamespace(otherDevice.id); + const otherSeal = await sealFor(otherNs); + await otherSeal.createSealedDek(PASS_NEW); + const otherRoot = await mountSealed(ns, otherSeal); const wrongKey = await refuses(() => guestRead(otherRoot, "checkpoint.bin")); // THE BYTES ON DISK. The marker is a string the guest wrote; if it @@ -583,7 +825,7 @@ const ops: Record Promise> = { plaintextOnDisk: rawText.includes("checkpoint plaintext"), magic, second, - cleanup: await cleanup([arg.id]), + cleanup: await cleanup([arg.id, otherDevice.id]), }; }, @@ -1160,7 +1402,7 @@ const ops: Record Promise> = { /** * PLANT A RIVAL IDENTITY in the device's namespace — a DIFFERENT but * perfectly valid non-extractable Ed25519 pair, exactly the shape - * `loadOrMintIdentity` would hand back. + * `identity.load-or-mint` would hand back. * * This is the wrong-device / corrupt-namespace case made reproducible. * The engine records the agent id in the checkpoint manifest, so the @@ -1172,20 +1414,23 @@ const ops: Record Promise> = { */ "hc-plant-identity": async (arg: { id: string }) => { const ns = openNamespace(arg.id); - const before = await loadIdentity(ns, DEVICE_IDENTITY_KEY); + const before = await ns.get("identity", IDENTITY_SLOT); const rival = await crypto.subtle.generateKey("Ed25519", false, [ "sign", "verify", ]) as CryptoKeyPair; - await persistIdentity(ns, DEVICE_IDENTITY_KEY, rival); - const after = await loadIdentity(ns, DEVICE_IDENTITY_KEY); + // Written straight into the slot: the plant IS the corrupt-namespace + // case, so it goes in the way an attacker's would rather than through + // a ceremony that would refuse it. + await ns.put("identity", IDENTITY_SLOT, rival); + const after = await ns.get("identity", IDENTITY_SLOT); return { - hadOne: before !== null, - planted: after !== null, + hadOne: before !== undefined, + planted: after !== undefined, // The rival is a real one: non-extractable, like every key this // store will accept. rivalExtractable: rival.privateKey.extractable, - different: before !== null && after !== null && + different: before !== undefined && after !== undefined && (await rawPublic(before)).byteLength > 0 && hexOf(await rawPublic(before)) !== hexOf(await rawPublic(after)), }; @@ -1200,7 +1445,7 @@ const ops: Record Promise> = { */ "hc-identity-at-rest": async (arg: { id: string }) => { const ns = openNamespace(arg.id); - const stored = await ns.get("identity", DEVICE_IDENTITY_KEY); + const stored = await ns.get("identity", IDENTITY_SLOT); if (!stored) return { present: false }; let exportRefused = false; try { @@ -1685,13 +1930,15 @@ const ops: Record Promise> = { * PLANT A PLATFORM WRAP beside a passkey device's PRF wrap — the * adversarial arm of the asked-to-be-asked ruling. Promotion to * `passkey` deletes the platform wrap; this puts one BACK (through - * seal.ts's own `enableUntilReseal`, which is exactly how a stale one + * the seal's own `enableUntilReseal`, which is exactly how a stale one * could exist), so the gate can assert that a `passkey`-policy unseal * still refuses to walk it silently (worker.ts's `climbRung`: the * passkey arm never falls to the platform wrap). */ "pk-plant-platform": async (arg: { id: string; passphrase: string }) => { - await enableUntilReseal(openNamespace(arg.id), arg.passphrase); + const ns = openNamespace(arg.id); + const seal = await sealFor(ns); + await seal.enableUntilReseal(arg.passphrase); return { planted: true }; }, @@ -2614,6 +2861,15 @@ async function saltOf(ns: DeviceNamespace): Promise { return hexOf(rec!.salt); } +/** A component-minted pair as the plain platform handles the signing + * helpers below take. `toCryptoKey()` is the port's own extraction seam + * (webcrypto#391); it launders the handle and preserves + * non-extractability, so this reads the pair without reading material. */ +const pairOf = (pair: [SigningKey, VerifyingKey]): CryptoKeyPair => ({ + privateKey: pair[0].toCryptoKey(), + publicKey: pair[1].toCryptoKey(), +}); + const sign = async (pair: CryptoKeyPair, msg: string) => new Uint8Array(await crypto.subtle.sign("Ed25519", pair.privateKey, enc.encode(msg) as BufferSource)); diff --git a/runtime/tests/devstore/run.ts b/runtime/tests/devstore/run.ts index 517a28d0..348cb357 100644 --- a/runtime/tests/devstore/run.ts +++ b/runtime/tests/devstore/run.ts @@ -669,23 +669,56 @@ async function main() { // --- 3: the passphrase rung ------------------------------------------- await guard(async () => { const r = await probe(page, "passphrase"); - const ok = r.state.passphrase && r.dekExtractable === false && + const ok = r.state.passphrase && r.parkedNotHanded && r.readBack === "sealed-kv-payload-TEST" && r.wrong.refused && r.wrong.error.code === "wrong-passphrase" && r.saltRotated && r.oldRefused.refused && r.stillReadable === "sealed-kv-payload-TEST" && r.secondMint.refused && r.secondMint.error.code === "already-sealed" && + r.forgotten && r.afterForget.refused && r.afterForget.error.code === "no-rung" && r.cleanup === "ok"; record( "3 seal", "every-session rung: unseal, refuse the wrong passphrase, rotate the salt on re-key", ok, - `the handed-out DEK is extractable=${r.dekExtractable}; unseal round-trips a sealed ` + + `NO DEK IS HANDED OUT AT ALL — it is parked in the component (${r.parkedNotHanded}); ` + + `unseal round-trips a sealed ` + `value (${j(r.readBack)}); wrong passphrase → ${r.wrong.error.name} ` + `code=${j(r.wrong.error.code)} and nothing was written; re-key rotates the 16-byte ` + `salt: ${r.saltRotated}, old passphrase then refused: ${r.oldRefused.refused}, and the ` + `SAME data still opens (${j(r.stillReadable)}) — the DEK did not rotate, by design; ` + - `a second mint is refused (${j(r.secondMint.error.code)})`, + `a second mint is refused (${j(r.secondMint.error.code)}); forget() re-seals ` + + `(${r.forgotten}) and the sealed surface then refuses ` + + `${j(r.afterForget.error.code)}`, + ); + }); + + // --- 3b: the on-disk compatibility fixture ----------------------------- + // + // THE PROOF THAT THE PORT MOVED NO BYTES. Every other row above seals + // and opens with the SAME build, so all of them would still pass if + // the format had changed in a self-consistent way. This one replays a + // device sealed by the pre-component seal.ts, captured once and + // committed, and is the only row that can fail for that reason. + await guard(async () => { + const fixture = JSON.parse( + await Deno.readTextFile(`${here}fixtures/legacy-seal-v1.json`), + ); + const r = await probe(page, "legacy-unseal", { fixture }); + const ok = r.state.passphrase && r.state.userPassphrase && r.opened && + r.kvMatches && r.fileMatches && + r.wrong.refused && r.wrong.error.code === "wrong-passphrase" && + r.cleanup === "ok"; + record( + "3b legacy", + "a device sealed by the PRE-COMPONENT seal.ts opens through the component", + ok, + `the captured wrap/sealed/file records were loaded verbatim into a fresh namespace; ` + + `state=${j(r.state)}; the passphrase opened it (${r.opened}); the sealed KV value ` + + `came back (${j(r.kv)}, matches: ${r.kvMatches}) and so did the PMSEALv1 file ` + + `(${j(r.file)}, matches: ${r.fileMatches}); a wrong passphrase still refuses ` + + `${j(r.wrong.error.code)}. This row fails if EITHER side drifts — the host's record ` + + `codec or the component's ladder and framing.`, ); }); @@ -707,19 +740,18 @@ async function main() { // --- 5: identity keys, across a REAL reload --------------------------- await guard(async () => { const mint = await probe(page, "identity-mint"); - const mintOk = mint.minted && mint.secondCallMinted === false && - mint.extractable === false && mint.raceSame && mint.raceMintedCount === 1 && - mint.extractableRefused.refused && - mint.extractableRefused.error.code === "extractable" && mint.signed; + const mintOk = mint.mintedOnFirstAsk && mint.secondAskSameKey && + mint.extractable === false && mint.raceSame && mint.slotsDiffer && mint.signed; record( "5 identity", - "non-extractable mint, race-free first mint, extractable key refused", + "non-extractable mint through the component, race-free first mint, two distinct slots", mintOk, - `minted=${mint.minted} (second call minted=${mint.secondCallMinted}); private half ` + - `extractable=${mint.extractable}; it signs and verifies: ${mint.signed}; two ` + - `concurrent loadOrMint → one minter (${mint.raceMintedCount}) and one key ` + - `(cross-verified: ${mint.raceSame}); persisting an EXTRACTABLE key is refused: ` + - `${mint.extractableRefused.error.name} code=${j(mint.extractableRefused.error.code)}`, + `the slot was empty and load-or-mint filled it (${mint.mintedOnFirstAsk}); the second ` + + `ask returns the STORED pair rather than a fresh mint (${mint.secondAskSameKey}); ` + + `private half extractable=${mint.extractable}; it signs and verifies: ${mint.signed}; ` + + `two CONCURRENT components over one namespace agree on one key ` + + `(cross-verified: ${mint.raceSame}) — the add-if-absent slot is what makes them; ` + + `the signing and endpoint slots are genuinely different keys (${mint.slotsDiffer})`, ); await page.reload({ waitUntil: "load" }); @@ -727,19 +759,24 @@ async function main() { const after = await probe(page, "identity-after", { id: mint.id }); const sameKey = after.publicKey === mint.publicKey && after.publicKey !== ""; const ok = after.loadedAfterReload && sameKey && after.signed && - after.junkRejected && after.junkDiscarded && - after.plantedRejected && after.plantedDiscarded && - after.remintedNonExtractable && after.cleanup === "ok"; + after.junkRejected && after.plantedRejected && after.plantedWarned && + after.remintedNonExtractable && after.plantedReplaced && + after.deleted && after.cleanup === "ok"; record( "5b identity", - "the handle survives a REAL reload and still signs; planted entries are discarded", + "the handle survives a REAL reload and still signs; planted entries read as absent", ok, `after navigation the stored pair loads (${after.loadedAfterReload}), is the SAME ` + `identity (public halves equal: ${sameKey}) and signs: ${after.signed}; a non-key ` + - `entry is rejected AND deleted (${after.junkRejected}/${after.junkDiscarded}); a ` + - `planted EXTRACTABLE pair likewise (${after.plantedRejected}/${after.plantedDiscarded}); ` + - `load-or-mint then produces a real non-extractable key rather than looping: ` + - `${after.remintedNonExtractable}`, + `entry reads as absent (${after.junkRejected}) and so does a planted EXTRACTABLE ` + + `pair written STRAIGHT INTO THE STORE, past the codec (${after.plantedRejected}) — ` + + `validate-on-load is the host's, and usableIdentity IN FULL rather than ` + + `fromCryptoKey alone, which never looks at extractable (world.wit:136-153); the ` + + `discard is VISIBLE rather than silent (${after.plantedWarned}: ` + + `${j(after.plantedWarning)}); load-or-mint then produces a real non-extractable key ` + + `rather than looping against the plant ` + + `(${after.remintedNonExtractable}, replaced: ${after.plantedReplaced}); ` + + `deleting the slot leaves it empty (${after.deleted})`, ); }); @@ -749,7 +786,7 @@ async function main() { await page.reload({ waitUntil: "load" }); await ready(page); const r = await probe(page, "platform-after", { id: arm.id }); - const ok = arm.state.untilReseal && r.autoUnsealed && r.autoExtractable === false && + const ok = arm.state.untilReseal && r.autoUnsealed && r.read === "survives-the-reload-TEST" && r.afterResealIsNull && r.state.untilReseal === false && r.state.passphrase && r.handleGone && r.stillOpens === "survives-the-reload-TEST" && r.cleanup === "ok"; @@ -758,8 +795,8 @@ async function main() { "auto-unseal after a REAL reload with NO passphrase; reseal puts the passphrase back", ok, `armed: ${j(arm.state)}; after navigation the DEK comes back from the non-extractable ` + - `platform key with no passphrase (${r.autoUnsealed}, extractable=${r.autoExtractable}) ` + - `and opens the sealed value (${j(r.read)}); after reseal(): auto-unseal is null ` + + `platform key with no passphrase (${r.autoUnsealed}) ` + + `and opens the sealed value (${j(r.read)}); after reseal(): auto-unseal answers false ` + `(${r.afterResealIsNull}), the key HANDLE is gone too (${r.handleGone}), state=${j(r.state)}, ` + `and the passphrase still opens the same data (${j(r.stillOpens)}). ` + `The honest sentence stands: this rung is login convenience, not protection ` + @@ -783,8 +820,8 @@ async function main() { ok, `write+read through wasi:filesystem/preopens@0.3 → openAt → writeViaStream/readViaStream ` + `(the spike's Q2 pattern) round-trips before the reload: ${w.ok}; after navigation, ` + - `re-mounting with the DEK recovered from the passphrase reads the guest's plaintext ` + - `back: ${r.ok}; a DIFFERENT DEK fails cleanly as a filesystem error ` + + `re-opening the seal with the passphrase reads the guest's plaintext ` + + `back: ${r.ok}; ANOTHER DEVICE'S SEAL fails cleanly as a filesystem error ` + `(the 0.3 completion future settles err: ${r.wrongKey.error.name} ` + `kind=${j(r.wrongKey.error.code)}), not a trap; the RAW ` + `OPFS file is ${r.rawLength} bytes beginning ${j(r.magic)}, and contains neither the ` +