Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions demo/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions runtime/PERSISTENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 44 additions & 21 deletions runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions runtime/device-seal/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
target/
build/
Loading
Loading