Skip to content

The device seal as a component (polymorph:webcrypto) - #144

Open
lannbot wants to merge 2 commits into
mainfrom
device-seal/webcrypto
Open

The device seal as a component (polymorph:webcrypto)#144
lannbot wants to merge 2 commits into
mainfrom
device-seal/webcrypto

Conversation

@lannbot

@lannbot lannbot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The per-device seal — the DEK, the KEK ladder (passphrase / until-reseal / passkey rungs), PMSEALv1 per-file sealing, and the device's Ed25519 signing handles — moves out of TypeScript into a wasm component, runtime/device-seal/ (polyvisor:device-seal@0.1.0), reaching WebCrypto through polymorph:webcrypto. seal.ts and identity-keys.ts are deleted; sealed-fs.ts keeps only the OPFS proxy.

What the boundary buys

  • The unsealed DEK exists nowhere in JavaScript: it is a resource inside the component, and the worker has no handle to export. Check (d)'s grep still runs, but there is nothing left for it to catch.
  • The component's reach is its imports — five record kinds and four key slots of one device's namespace, closed over at instantiation. It cannot name another database or localStorage.
  • The ladder's rules are natively tested (cargo test, 31) rather than only browser-observable.
  • Persisted key handles cross via polymorph-webcrypto#391's fromCryptoKey/toCryptoKey, so non-extractability remains the platform's own flag.

What did not change

  • The on-disk format, byte for byte. tests/devstore/fixtures/legacy-seal-v1.json is a device sealed by the pre-component code; row 3b legacy opens it through the component every run.
  • The PRF derivation stays on the page per PERSISTENCE.md; the KEK enters as a non-extractable kw-key.
  • Every refusal sentence the sheets render (seal-error is {code, message}, seal.ts's wording ported verbatim).

Gates

  • runtime$ just test: 74 PASS / 4 INFO / 0 FAIL (the whole matrix, plus the legacy row and a planted-extractable-identity assertion).
  • cargo test: 31; clippy clean both targets; wasm-tools validate ok.
  • demo$ just e2e: 35/36 — firefox-smoke fails with the identical symptom on main's latest CI run (33978040646), so it is pre-existing, not this change.
  • check-invariants.sh: clean. Worker bundle +2.9 KB. Artifact 282,520 raw / 80,739 gzipped.

Two contract defects were found by the gates and fixed in the same branch: my WIT claimed fromCryptoKey covered validate-on-load (it doesn't check extractable — row 5b), and the AAD is the 8-byte magic rather than the header (sealed-fs.ts:127). demo/justfile and both workflows' cargo caches now carry the new crate.

Automerge is armed.

…ver polymorph:webcrypto

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.
@lannbot
lannbot enabled auto-merge September 5, 2026 16:50
…ync export

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.
@lannbot

lannbot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up commit: the firefox-smoke failure was mine, not pre-existing — a clean main passes it locally; the identical failure on main's CI was coincidence. Root cause: polyengine auto-selected jspi mode for the seal component (wit-bindgen's sync-form cancel built-ins classify as block-capable), which promising-wraps every export, and Gecko's pref-gated JSPI then kills the content process on the first async export call. The component needs no suspension (every import is an async func), so it is now instantiated with jspi: false. Firefox lane green; matrix 74/0; full e2e 35/36 with the known-flaky store-outage-recovery passing alone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants