Skip to content

wit: platform-backed key storage for non-extractable keys #97

Closed as not planned

Description

@lann

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

  1. 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-key take the flag, so the
    minting component knows the answer; a loaded key's flag comes from storage.
    This is why wit: verifying-key.export-key is async but total; extractable is a getter on signing-key alone #86 adds extractable getters to the symmetric key resources.
    [[algorithm]] survives the clone, so the algorithm-* getters stay
    answerable — that should be stated, not assumed.

  2. 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 stored mac-key cannot be retrieved
    as an aead-key.

  3. 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.

  4. 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 at wac plug time. That makes
    storage an optional target capability in conformance/targets.toml rather
    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 store takes a borrow of a live key resource, or whether keys are
    minted directly into storage.

Activity

  1. lann commented on Jul 29, 2026

    @lann
    CollaboratorAuthor

    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 a wit-bindgen caller cannot currently do is distinguish a cancel-before-start from a cancel-after-start: Subtask::call maps both to unreachable!("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 store cancelled after start may or may not have written; a store cancelled 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 design store so 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.

  2. lann commented on Jul 31, 2026

    @lann
    CollaboratorAuthor

    Design development in #128: usages join fallible export-key and the extractable getter 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.

  3. lann commented on Aug 8, 2026

    @lann
    CollaboratorAuthor

    Two design developments from discussion (2026-08-07).

    The interface belongs in a sibling package, not polymorph:webcrypto

    This issue's framing ("a polymorph:webcrypto interface 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 a CryptoKey), but the keystore API itself is IndexedDB + structured clone — different specs. The storage interface is therefore a sibling package (working name polymorph:webcrypto-keystore) that imports the key resource types from polymorph:webcrypto.

    This does not disturb the storage-forced changes already on the stable surface (fallible export, the extractable getters, usages): those are properties of the key resources, which stay in polymorph: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 prf extension (CTAP hmac-secret underneath) yields, per navigator.credentials.get() ceremony, 32 pseudorandom bytes per eval input, deterministic for a given (credential, input) pair. The flow:

    1. The embedder runs the ceremony and receives the PRF output (it is exposed to page JS — the browser shields it from the RP, not from the host).
    2. The host imports it: importKey('raw', prfOutput, 'HKDF', false, [...]). HKDF import requires extractable: false per spec, so the key is non-extractable by construction, and HKDF CryptoKeys are structured-cloneable, so it persists in IndexedDB like any other.
    3. The guest reaches it through the keystore's load-* path as a derivation-key handle. 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:

    • It strengthens constraint 1 ("load is a minting path for keys the caller did not mint"): PRF-provisioned keys have no package-side mint at all, so every getter contract must hold for them.
    • It is partly a substitute for storage: the authenticator is itself a keystore — the same (credential, eval input) re-derives the same bytes forever, so a guest whose only long-lived secret is PRF-rooted can re-derive per session instead of persisting. Storage still earns its keep for keys not derivable from the PRF and for skipping a user-verification ceremony per launch; the two compose (PRF-rooted keys stored to avoid re-ceremony).
    • Capability spread: browser-only in practice (the Wasmtime host reaching CTAP hmac-secret via 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."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions