From 7117322a009c3700723ef4daabd5e457ae90f8e5 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sat, 5 Sep 2026 12:50:19 -0400 Subject: [PATCH 1/2] The device seal as a component: DEK, KEK ladder, PMSEALv1, identity over polymorph:webcrypto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WHAT MOVED. runtime/device-store/seal.ts (837), identity-keys.ts (234) and the AES-GCM half of sealed-fs.ts are gone; their rules live in runtime/device-seal/, a wasm32-wasip2 component (polyvisor:device-seal @0.1.0, contract wit/world.wit) that reaches WebCrypto through the polymorph:webcrypto guest bindings and the same @polymorph/webcrypto host module the engine already instantiates with. The worker holds a component and asks it to seal and open bytes; seal-component.ts is the adapter, seal-records.ts the record shapes plus the one keyless reader (getPrfEnrollment), and the namespace import is a field-mapping codec with no decisions in it. WHAT IT BUYS. The unsealed DEK exists nowhere in JavaScript — a resource in the component, not a CryptoKey variable a grep guards. The component's reach is its imports: five record kinds and four key slots of ONE device namespace, closed over at instantiation. The ladder's rules ("absent origin means generated", the PMSEALv1 layout, every refusal's code and sentence) are 31 native cargo tests instead of only browser rows. Persisted handles cross through polymorph-webcrypto#391's fromCryptoKey/toCryptoKey seam, so non-extractability is still the platform's flag. WHAT DID NOT CHANGE. The on-disk format, by requirement: tests/devstore/fixtures/legacy-seal-v1.json is a device sealed by the pre-component seal.ts, and matrix row 3b opens it through the component every run. The PRF derivation stays on the page (PERSISTENCE.md's ruling); the KEK enters as a non-extractable kw-key. Validate-on-load for identity stays host-side — the add-if-absent transaction must apply it — and the component re-checks extractable() so a codec bug fails loud (row 5b plants an extractable pair). FOUND ON THE WAY. My first WIT said fromCryptoKey's refusals were validate-on-load; they do not check extractable (row 5b caught it). The AAD is the 8-byte magic, not the header (sealed-fs.ts:127). And the refusal SENTENCE is copy the visor renders: seal-error is now {code, message} with seal.ts's eleven sentences ported word for word, because solo-persistence asserts one of them — the runtime matrix could not see that, the demo e2e could. Gates: runtime matrix 74 PASS / 0 FAIL; cargo test 31; demo e2e 35/36, the one failure (firefox-smoke) reproducing identically on main's own latest CI run; check-invariants clean; worker bundle +2.9 KB; artifact 282,520 raw / 80,739 gzipped. --- .github/workflows/e2e.yml | 1 + .github/workflows/pages.yml | 1 + demo/justfile | 13 +- runtime/PERSISTENCE.md | 14 + runtime/README.md | 65 +- runtime/device-seal/.gitignore | 2 + runtime/device-seal/Cargo.lock | 433 +++++++++ runtime/device-seal/Cargo.toml | 36 + runtime/device-seal/justfile | 28 + runtime/device-seal/rust-toolchain.toml | 5 + runtime/device-seal/src/component.rs | 505 +++++++++++ runtime/device-seal/src/file_format.rs | 190 ++++ runtime/device-seal/src/identity.rs | 110 +++ runtime/device-seal/src/ladder.rs | 268 ++++++ runtime/device-seal/src/lib.rs | 74 ++ runtime/device-seal/src/records.rs | 773 ++++++++++++++++ runtime/device-seal/src/state.rs | 46 + .../wit/deps/polymorph-webcrypto/aes.wit | 282 ++++++ .../deps/polymorph-webcrypto/agreement.wit | 134 +++ .../deps/polymorph-webcrypto/derivation.wit | 87 ++ .../wit/deps/polymorph-webcrypto/ecdh.wit | 123 +++ .../wit/deps/polymorph-webcrypto/ecdsa.wit | 138 +++ .../wit/deps/polymorph-webcrypto/ed25519.wit | 101 +++ .../deps/polymorph-webcrypto/encryption.wit | 154 ++++ .../wit/deps/polymorph-webcrypto/hkdf.wit | 106 +++ .../wit/deps/polymorph-webcrypto/hmac.wit | 131 +++ .../wit/deps/polymorph-webcrypto/pbkdf2.wit | 110 +++ .../wit/deps/polymorph-webcrypto/rsa.wit | 330 +++++++ .../wit/deps/polymorph-webcrypto/sha1.wit | 66 ++ .../wit/deps/polymorph-webcrypto/sha2.wit | 34 + .../deps/polymorph-webcrypto/webcrypto.wit | 747 ++++++++++++++++ .../wit/deps/polymorph-webcrypto/wrapping.wit | 178 ++++ .../wit/deps/polymorph-webcrypto/x25519.wit | 85 ++ .../wit/deps/wasi-random/random.wit | 17 + runtime/device-seal/wit/world.wit | 400 +++++++++ runtime/device-store/identity-keys.ts | 234 ----- runtime/device-store/index.ts | 6 +- runtime/device-store/mod.ts | 42 +- runtime/device-store/namespace.ts | 6 +- runtime/device-store/passkey.ts | 10 +- runtime/device-store/rpc.ts | 15 +- runtime/device-store/seal-component.ts | 690 +++++++++++++++ runtime/device-store/seal-records.ts | 271 ++++++ runtime/device-store/seal.ts | 837 ------------------ runtime/device-store/sealed-fs.ts | 133 +-- runtime/device-store/worker.ts | 446 ++++++---- runtime/justfile | 15 +- .../devstore/fixtures/legacy-seal-v1.json | 56 ++ runtime/tests/devstore/page.ts | 494 ++++++++--- runtime/tests/devstore/run.ts | 89 +- 50 files changed, 7621 insertions(+), 1510 deletions(-) create mode 100644 runtime/device-seal/.gitignore create mode 100644 runtime/device-seal/Cargo.lock create mode 100644 runtime/device-seal/Cargo.toml create mode 100644 runtime/device-seal/justfile create mode 100644 runtime/device-seal/rust-toolchain.toml create mode 100644 runtime/device-seal/src/component.rs create mode 100644 runtime/device-seal/src/file_format.rs create mode 100644 runtime/device-seal/src/identity.rs create mode 100644 runtime/device-seal/src/ladder.rs create mode 100644 runtime/device-seal/src/lib.rs create mode 100644 runtime/device-seal/src/records.rs create mode 100644 runtime/device-seal/src/state.rs create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/aes.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/agreement.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/derivation.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/ecdh.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/ecdsa.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/ed25519.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/encryption.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/hkdf.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/hmac.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/pbkdf2.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/rsa.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/sha1.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/sha2.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/webcrypto.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/wrapping.wit create mode 100644 runtime/device-seal/wit/deps/polymorph-webcrypto/x25519.wit create mode 100644 runtime/device-seal/wit/deps/wasi-random/random.wit create mode 100644 runtime/device-seal/wit/world.wit delete mode 100644 runtime/device-store/identity-keys.ts create mode 100644 runtime/device-store/seal-component.ts create mode 100644 runtime/device-store/seal-records.ts delete mode 100644 runtime/device-store/seal.ts create mode 100644 runtime/tests/devstore/fixtures/legacy-seal-v1.json 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..9914c65d --- /dev/null +++ b/runtime/device-store/seal-component.ts @@ -0,0 +1,690 @@ +// 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 { + const instance = await instantiate(source, { + ...wasi({ cli: { args: [`device-seal-${ns.id.slice(0, 8)}`] } }), + ...webcryptoImports(), + [I_TYPES]: {}, + [I_NAMESPACE]: namespaceImports(ns), + }); + + 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 ` + From b3f2edc8a09fb1706506622f48d6bda9554870fb Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sat, 5 Sep 2026 14:04:40 -0400 Subject: [PATCH 2/2] =?UTF-8?q?device-seal:=20pin=20plain=20mode=20?= =?UTF-8?q?=E2=80=94=20jspi=20mode=20crashes=20Gecko=20on=20the=20first=20?= =?UTF-8?q?async=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firefox-smoke caught it and a minimal page probe isolated it: with polyengine's auto-detected suspension mode the seal component's first `async func` export kills the Firefox content process; the identical sequence passes with `jspi: false`. The component never needs a suspended frame — every import it calls is an `async func` on the component-model async ABI — but wit-bindgen's sync-form cancel built-ins for the drop-a-pending-future path classify as block-capable and tip auto-detection into jspi, which promising-wraps every export. Under Gecko's pref-gated JSPI that is fatal. Plain mode is what the component actually is; a wrongly-sync import would be refused loudly (NeedsJspi), never degraded. Gates: runtime matrix 74/0; firefox-smoke + cross-engine-pairing green; full demo e2e 35/36 with store-outage-recovery flaking once and passing alone (the known timing-sensitive one). I had earlier read the identical firefox-smoke failure on main's own CI as pre-existing; a clean local main passes it, so that read was wrong and this commit is the fix. --- runtime/device-store/seal-component.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/runtime/device-store/seal-component.ts b/runtime/device-store/seal-component.ts index 9914c65d..b17276ff 100644 --- a/runtime/device-store/seal-component.ts +++ b/runtime/device-store/seal-component.ts @@ -579,12 +579,33 @@ 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;