Repository navigation
wit: platform-backed key storage for non-extractable keys #97
Description
Keys minted through import-key/generate-key live only as long as the
component instance that minted them. A guest that wants a long-lived key must
export the material and re-import it later, which forces extractable: true —
exactly the property the capability model exists to avoid.
Browser WebCrypto already solves this without exposing material. CryptoKey
has serialization and deserialization steps (WebCrypto Level 2 §13.5), so a
key is structured-cloneable and can be stored in IndexedDB. The steps preserve
[[extractable]] and [[handle]], and the spec's note states the intent:
deserialization must not expose material "in some cases (such as when the
[[extractable]] internal slot is false)". A JS host can therefore persist a
non-extractable key and hand it back later as a working capability.
The goal is a lann:webcrypto interface that mints key resources from
host-backed storage.
What a stored key is
A retrieved key is a handle, not bytes. WebCrypto draws this line
deliberately: every algorithm's Export Key operation starts with "if the
underlying cryptographic key material represented by the [[handle]] internal
slot of key cannot be accessed, then throw an OperationError" (e.g. §23.7.5
ECDSA, §25.3.5 Ed25519), while the Sign and Verify operations have no such
step. A key may be usable and unreadable at the same time.
The package's stable layer has to allow that. #86 resolves the one surface that
did not (verifying-key.export-key was total).
Constraints already visible
-
Load is a new minting path. Every contract phrased as "the caller chose
this at mint" has to hold for a key the caller did not mint.extractable
is the concrete case:import-key/generate-keytake the flag, so the
minting component knows the answer; a loaded key's flag comes from storage.
This is why wit:verifying-key.export-keyis async but total;extractableis a getter onsigning-keyalone #86 addsextractablegetters to the symmetric key resources.
[[algorithm]]survives the clone, so thealgorithm-*getters stay
answerable — that should be stated, not assumed. -
Load is typed per kind. WIT cannot return "some key". Loading splits the
way minting splits, and the stored algorithm must be checked against the
requested resource type at load, so a storedmac-keycannot be retrieved
as anaead-key. -
Identifiers are the guest's. The guest names keys and the host maps
names onto its storage. A platform-shaped handle in the stable layer would
leak the host's storage model into every consumer. -
This is the first capability-shaped divergence between implementations.
Algorithm coverage differs today; storage differs in kind. jco has
IndexedDB. The Wasmtime host needs a filesystem or OS-keychain backing. The
in-guest provider has no store at all and declines the interface, as it
declines class D — the composition then fails atwac plugtime. That makes
storage an optional target capability inconformance/targets.tomlrather
than a required feature, and it stresses the "keep the implementations
behaviourally in sync" invariant in a way algorithm growth does not.
Open questions
- Deletion and enumeration: does the interface own a key's lifetime, or only
its retrieval? - Whether a stored key's usages restrict what the retrieved resource can do,
or whether the resource type alone carries that (the package's answer so far
is the type). - Whether
storetakes aborrowof a live key resource, or whether keys are
minted directly into storage.
Activity
wit: make public-key export fallible; report extractability on every …
lann commentedon Jul 29, 2026
A note from #103, to be checked when this is designed rather than after.
The component model lets a callee hold a call in the starting state, and a caller cancel it there — cleanly, with no callee code run at all (
STATUS_STARTED_CANCELLED, which re-acquires the lowered parameters so the caller's destructors run). What awit-bindgencaller cannot currently do is distinguish a cancel-before-start from a cancel-after-start:Subtask::callmaps both tounreachable!("cancellation is not exposed API-wise").For every operation in the package today that gap is harmless — the one candidate,
internal-nonce-key.seal's nonce budget, is already documented as inexact ("MAY decrement faster than one per seal"), so the contract absorbs it.A storage interface is the first place it would stop being harmless. A
storecancelled after start may or may not have written; astorecancelled before start certainly did not. If the interface is designed so a caller must reconcile that, it needs an observability capability that does not exist yet. The cheaper answer is to designstoreso the distinction does not matter — idempotent under a caller-supplied identifier, so a cancelled-and-retried store converges regardless of which side of the boundary it was cancelled on. Worth deciding deliberately rather than discovering later.lann commentedon Jul 31, 2026
Design development in #128: usages join fallible
export-keyand theextractablegetter as the third consequence storage forces onto the stable surface — structured clone carries[[usages]], and keystore-resident keys not minted through the package arrive with restrictions the interface must be able to interrogate (can-*getters), honor (not-permitted), and — for keys we mint — record at the platform's write-once moment (mint-time options). The store-time enforcement test articulated there ("a WIT-layer abstraction must not promise what the platform artifact does not carry") is a design constraint for this issue's interface shape generally.lann commentedon Aug 8, 2026
Two design developments from discussion (2026-08-07).
The interface belongs in a sibling package, not
polymorph:webcryptoThis issue's framing ("a
polymorph:webcryptointerface that mints key resources from host-backed storage") predates the sibling-package rule and should be corrected rather than defended. The package tracks the WebCrypto standard, and a store/load-by-identifier surface is not SubtleCrypto: WebCrypto supplies only the enabling machinery (§13's structured-clone steps exist precisely so IndexedDB can hold aCryptoKey), but the keystore API itself is IndexedDB + structured clone — different specs. The storage interface is therefore a sibling package (working namepolymorph:webcrypto-keystore) that imports the key resource types frompolymorph:webcrypto.This does not disturb the storage-forced changes already on the stable surface (fallible export, the
extractablegetters, usages): those are properties of the key resources, which stay inpolymorph:webcrypto, and they are what makes a sibling package able to hand back honest handles at all.It does still get a WIT interface, unlike the PRF source below — the distinction is agency, not spec boundary. The guest holds everything that matters for storage (which keys it minted, when to persist, rotate, delete); the embedder cannot drive store/load on the guest's behalf because it does not know the guest's keys or lifecycle. Embedder-side injection covers exactly the keys the embedder provisioned and nothing the guest generated at runtime.
Passkey PRF as a source of keystore-resident keys — zero WIT
The WebAuthn
prfextension (CTAPhmac-secretunderneath) yields, pernavigator.credentials.get()ceremony, 32 pseudorandom bytes per eval input, deterministic for a given (credential, input) pair. The flow:importKey('raw', prfOutput, 'HKDF', false, [...]). HKDF import requiresextractable: falseper spec, so the key is non-extractable by construction, and HKDFCryptoKeys are structured-cloneable, so it persists in IndexedDB like any other.load-*path as aderivation-keyhandle. The secret never enters wasm linear memory.No WIT surface is needed: the ceremony requires transient user activation and credential-selection UX, so a guest-triggered import would routinely fail — the embedder holds everything needed to drive it, at a UX-appropriate moment. Passkey PRF is documentation plus (at most) a jco host helper. A guest-driven ceremony interface (dynamic eval inputs, credential enumeration) would be a second sibling package, deferred until a consumer demands it.
Consequences for this design:
hmac-secretvia libfido2 is realistically out of scope). Under the zero-WIT shape this costs nothing — it is just "what the embedder put in the store varies by embedding."inject: embedder-held CryptoKeys as typed key handles; the WIT keysto…