From 355651a78bbc552c4062d6365cd21ea762ed10e9 Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Sat, 22 Aug 2026 13:34:35 -0400 Subject: [PATCH] keystore: signing keys survive instantiations (polymorph:webcrypto-keystore 0.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted-key slice (#389; closed-#97's sibling-package ruling honored): a NEW WIT package rather than an extension to polymorph:webcrypto, which tracks the WebCrypto standard and takes no extensions. signing-keystore stores a signing-key HANDLE under a guest-chosen name inside an embedder-chosen storage root; no key material crosses the interface in either direction, so a non-extractable key stays non-extractable forever — the property the alternative (export + re-import) cannot carry. Host module js/polyengine/src/keystore.ts, exported as @polymorph/webcrypto/keystore: one IndexedDB database per namespace, CryptoKey handles via structured clone (WebCrypto section-13 steps). Both edges enforce extractability: persist refuses an extractable key; load re-validates algorithm/type/usages/extractable===false and DISCARDS a failing entry (IndexedDB is origin-writable, so a stored entry is untrusted input on the way back in — wosh identity-store's validated predicate). The mint-bound algorithm record is rebuilt from the module constant, never read from storage. Ed25519 only in v1, per the #389 kind-coverage ruling. Gated by a new Playwright browser probe (Deno has no IndexedDB): persist -> real page reload -> load -> sign verified against the pre-reload public half, both extractability edges, planted-entry discards, namespace isolation, no-keystore refusal — 10/10. WIT validation covers the new package; publish dry-run clean; conformance failure sets byte-identical to baseline (the declared #351 debt). First consumer: polyvisor's G5 device store (platform-posture identity resume; its runtime/PERSISTENCE.md T-A). Additive surface: 0.3.1 on the next release commit. --- AGENTS.md | 7 + README.md | 3 + js/polyengine/README.md | 32 ++ js/polyengine/deno.json | 6 +- js/polyengine/src/keystore.ts | 293 ++++++++++++++++++ js/polyengine/src/signature.ts | 3 + js/polyengine/tests/browser/package-lock.json | 25 ++ js/polyengine/tests/browser/package.json | 9 + js/polyengine/tests/browser/probe-entry.ts | 241 ++++++++++++++ js/polyengine/tests/browser/run.mjs | 166 ++++++++++ justfile | 20 ++ wit-keystore/README.md | 60 ++++ wit-keystore/deps/polymorph-webcrypto | 1 + wit-keystore/keystore.wit | 73 +++++ 14 files changed, 938 insertions(+), 1 deletion(-) create mode 100644 js/polyengine/src/keystore.ts create mode 100644 js/polyengine/tests/browser/package-lock.json create mode 100644 js/polyengine/tests/browser/package.json create mode 100644 js/polyengine/tests/browser/probe-entry.ts create mode 100644 js/polyengine/tests/browser/run.mjs create mode 100644 wit-keystore/README.md create mode 120000 wit-keystore/deps/polymorph-webcrypto create mode 100644 wit-keystore/keystore.wit diff --git a/AGENTS.md b/AGENTS.md index 8872fba..8936dcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,13 @@ wit/ # the polymorph:webcrypto package, one file per layer: # extension-conditions.json is the registry of # the package's named extension conditions (see # wit/README.md, "Error contract") +wit-keystore/ # the polymorph:webcrypto-keystore package: the + # SIBLING package for keeping a signing key + # across instantiations by name (issue #389's + # ruling — the store/load surface is not + # SubtleCrypto, so it does not enter the + # package above); pulls that package in through + # the same deps/ symlink components use rust/ # the Rust library surface (directory = crate name # minus the `polymorph-webcrypto-` family root) core/ # polymorph-webcrypto-core: the shared RustCrypto core of diff --git a/README.md b/README.md index 0760fba..e4d9f73 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,9 @@ are specified in [`wit/README.md`](wit/README.md) and the WIT doc comments. ``` wit/ # the polymorph:webcrypto package (defined once, here) +wit-keystore/ # the polymorph:webcrypto-keystore sibling package: + # store/load a signing key by name, no material + # crossing the interface rust/ # the Rust crates (dir = crate name minus the # `polymorph-webcrypto-` family root) core/ # shared RustCrypto core of both Rust diff --git a/js/polyengine/README.md b/js/polyengine/README.md index c723c39..79dd364 100644 --- a/js/polyengine/README.md +++ b/js/polyengine/README.md @@ -36,6 +36,38 @@ host's Node posture — Deno's `crypto.subtle` mints those keys. A browser-hosted embedding should call `setRsaPrivateKeyPolicy("decline")` (`src/rsaSignature.ts`), the posture `jco-browser` runs under. +## The keystore module + +`src/keystore.ts` (`@polymorph/webcrypto/keystore`) serves a *different* +WIT package: [`polymorph:webcrypto-keystore`](../../wit-keystore), which +keeps a signing key across instantiations by a name the guest chooses. +`keystoreImports({ namespace })` is its entry point, and `namespace` is +the IndexedDB database the embedder assigns — persistence is a capability +the embedder grants, so `keystoreImports()` with no argument returns +functions that refuse. + +```ts +instantiate(artifacts, { + ...wasi(), + ...webcryptoImports(), + ...keystoreImports({ namespace: "pm-device-7" }), +}); +``` + +No key material crosses the interface in either direction: what IndexedDB +holds is the `CryptoKey` handle, structured-cloned, so a non-extractable +key survives a reload with its material still unreadable. The module +refuses to store an extractable key, and re-validates every entry on the +way back out (algorithm, key type, usages, `extractable`), because +IndexedDB is writable by anything else in the origin. + +Its gate is `just polyengine-keystore-probe` — a Playwright-driven +Chromium page (`tests/browser/`) covering the store/reload/load/sign round +trip, the extractability refusals on both edges, the missing-name and +no-keystore answers, and namespace isolation. It is a separate lane +because Deno has no IndexedDB, so `deno task test` cannot observe any of +it. + ## Module identity `deno.json`'s `@polyengine/runtime/embedder` import maps to the exact same diff --git a/js/polyengine/deno.json b/js/polyengine/deno.json index d071710..653e606 100644 --- a/js/polyengine/deno.json +++ b/js/polyengine/deno.json @@ -2,12 +2,16 @@ "name": "@polymorph/webcrypto", "version": "0.3.0", "license": "Apache-2.0", - "exports": "./src/mod.ts", + "exports": { + ".": "./src/mod.ts", + "./keystore": "./src/keystore.ts" + }, "//": "MODULE-IDENTITY CONSTRAINT: polyengine's wasi module imports @polyengine/runtime/embedder by bare specifier internally. This file's @polyengine/runtime/embedder entry is a caret range (published-package convention: consumers resolve one shared @polyengine/runtime across their dependency graph); conformance/driver-ct/polyengine/deno.json (repo-internal, not published) exact-pins the same package for its own module-identity needs. The pin gate (just conformance-ct::polyengine-pin-check) asserts the two deno.locks resolve to one @polyengine/runtime version repo-wide.", "imports": { "@polyengine/runtime/embedder": "jsr:@polyengine/runtime@^0.3.0/embedder" }, "minimumDependencyAge": { "age": "P1D", "exclude": ["jsr:@polyengine/*"] }, + "exclude": ["tests/browser/run.mjs", "tests/browser/node_modules"], "publish": { "exclude": ["tests"] }, diff --git a/js/polyengine/src/keystore.ts b/js/polyengine/src/keystore.ts new file mode 100644 index 0000000..38a11e6 --- /dev/null +++ b/js/polyengine/src/keystore.ts @@ -0,0 +1,293 @@ +// The `polymorph:webcrypto-keystore@0.1.0` host module for +// [polyengine](https://github.com/polymorph-components/polyengine): `keystoreImports()` +// returns +// the imports-record fragment serving `signing-keystore` over IndexedDB. +// +// The WIT contract is [`wit-keystore/`](../../../wit-keystore) — a +// SIBLING package to `polymorph:webcrypto`, which tracks the WebCrypto +// standard and takes no extensions to it (AGENTS.md, "What this +// repository is"; the ruling and its design record are issue #389). +// The keystore imports the `signing-key` resource from that package and +// adds no cryptography of its own. +// +// What makes this possible without any key material moving: 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 page reload while its material stays +// unreadable — the property this module exists to carry across +// instantiations, and the reason the alternative (export material, +// re-import next time) is a downgrade rather than an equivalent. +// +// Storage layout: one IndexedDB database per namespace — the embedder's +// storage root, fixed before the guest runs — holding a single object +// store of key handles keyed by the guest's identifiers. +// +// Two edges enforce non-extractability, because they fail differently. +// `persist-signing-key` refuses an extractable key: today the +// `polymorph:webcrypto` port mints signing keys non-extractable unless +// the caller asked otherwise (signature.ts `SigningKeyOptions`, +// defaulting `extractable: false`, threaded through +// `ed25519Sign.generateKey`), so the refusal is a check on the caller's +// mint, not on this port's default. `loadSigningKey` re-validates every +// stored entry — algorithm, key type, usages, and `extractable === false` +// — because IndexedDB is writable by anything else in the same origin, +// so an entry is untrusted input on the way back in (the shape is wosh's +// validated `usable()` predicate, site/identity-store.ts:67-76). + +import { ComponentException } from "@polyengine/runtime/embedder"; +import { ED25519_ALGORITHM, SigningKey } from "./signature.ts"; + +/** Where a keystore keeps its entries. The embedder chooses it; the guest never names it. */ +export interface KeystoreOptions { + /** + * The storage root: the IndexedDB database name. Every identifier the + * guest uses is scoped inside it, so two namespaces cannot see each + * other's keys. + */ + namespace: string; +} + +/** The object store inside a namespace database. */ +const STORE = "signing-keys"; + +/** The database version this module creates and expects. */ +const DB_VERSION = 1; + +// The slice of IndexedDB this module uses, typed structurally rather +// than through the DOM lib. A published module cannot pull the DOM +// globals into its consumers' type environment (JSR bans the triple +// slash directive that would), and nothing here declares a global, so +// the module type-checks the same whether or not the consumer has the +// DOM lib loaded. + +interface IdbRequestLike { + readonly result: T; + readonly error: unknown; + onsuccess: (() => void) | null; + onerror: (() => void) | null; +} + +interface IdbOpenRequestLike extends IdbRequestLike { + onupgradeneeded: (() => void) | null; + onblocked: (() => void) | null; +} + +interface IdbObjectStoreLike { + get(key: string): IdbRequestLike; + put(value: unknown, key: string): unknown; + delete(key: string): unknown; +} + +interface IdbTransactionLike { + readonly error: unknown; + objectStore(name: string): IdbObjectStoreLike; + oncomplete: (() => void) | null; + onabort: (() => void) | null; + onerror: (() => void) | null; +} + +interface IdbDatabaseLike { + readonly objectStoreNames: { contains(name: string): boolean }; + createObjectStore(name: string): unknown; + transaction(store: string, mode?: "readonly" | "readwrite"): IdbTransactionLike; + close(): void; +} + +interface IdbFactoryLike { + open(name: string, version?: number): IdbOpenRequestLike; +} + +/** This platform's IndexedDB, or `undefined` where there is none (Deno, some private-browsing modes). */ +function indexedDbFactory(): IdbFactoryLike | undefined { + return (globalThis as { indexedDB?: IdbFactoryLike }).indexedDB; +} + +/** Throw the WIT `result<_, string>` err arm. */ +function keystoreError(detail: string): never { + throw new ComponentException(detail); +} + +const req = (r: IdbRequestLike): Promise => + new Promise((resolve, reject) => { + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + +const committed = (tx: IdbTransactionLike): Promise => + new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = tx.onerror = () => reject(tx.error); + }); + +function openDb(namespace: string): Promise { + return new Promise((resolve, reject) => { + const factory = indexedDbFactory(); + if (factory === undefined) { + reject(new Error("this platform has no IndexedDB")); + return; + } + const open = factory.open(namespace, DB_VERSION); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + open.onsuccess = () => resolve(open.result); + open.onerror = () => reject(open.error); + // Another connection is holding an older version open. Rejecting + // beats waiting: `blocked` has no timeout of its own, and the guest + // can retry. + open.onblocked = () => reject(new Error("IndexedDB open blocked by another connection")); + }); +} + +/** + * Run `body` against the namespace database, mapping a storage refusal + * onto the WIT err arm. + * + * Some private-browsing modes offer no IndexedDB at all, which is a + * condition rather than a fault: the guest is told the keystore is + * unavailable, and decides for itself whether to run without persistence + * (the degradation the first consumer performs) or to stop. + */ +async function withDb(namespace: string, body: (db: IdbDatabaseLike) => Promise): Promise { + let db: IdbDatabaseLike; + try { + db = await openDb(namespace); + } catch (e) { + keystoreError(`keystore unavailable: ${(e as Error)?.message ?? e}`); + } + try { + return await body(db); + } catch (e) { + if (e instanceof ComponentException) throw e; + keystoreError(`keystore unavailable: ${(e as Error)?.message ?? e}`); + } finally { + db.close(); + } +} + +/** + * Whether a stored value is a key this module is willing to hand back: + * exactly what a conforming `persist-signing-key` accepted, re-checked + * rather than assumed. + * + * `extractable === false` is the load-side half of the promise the + * interface makes about stored keys. The algorithm check is what keeps + * the mint-bound algorithm record honest: the record is reconstructed + * from a constant here, never read from storage, so a rewritten entry + * cannot switch a per-operation binding. + */ +function usableSigningKey(value: unknown): value is CryptoKey { + return ( + value instanceof CryptoKey && + value.type === "private" && + value.algorithm.name === "Ed25519" && + value.extractable === false && + value.usages.includes("sign") + ); +} + +/** Remove an entry that failed validation, so the caller's mint-and-store path is not a loop. */ +async function discard(db: IdbDatabaseLike, id: string): Promise { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(id); + await committed(tx); +} + +function requireId(id: string): void { + if (id === "") keystoreError("a keystore identifier must not be empty"); +} + +function servedKeystore(options: KeystoreOptions) { + const { namespace } = options; + return { + /** + * `signing-keystore.persist-signing-key`: store the key's handle + * under `id`, replacing any entry already there (the interface's + * idempotence under `id`). + */ + persistSigningKey: async (key: SigningKey, id: string): Promise => { + requireId(id); + if (key.extractable()) { + keystoreError( + "an extractable signing key cannot be stored: a stored key promises material that was never readable", + ); + } + if (key.algorithmName() !== "Ed25519") { + keystoreError( + `this keystore stores Ed25519 signing keys; this key is ${key.algorithmName()}`, + ); + } + await withDb(namespace, async (db) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).put(key.cryptoKey, id); + await committed(tx); + }); + }, + + /** + * `signing-keystore.load-signing-key`: the key stored under `id`, or + * `undefined` (the WIT `none`) when this namespace holds no usable + * key by that name. + */ + loadSigningKey: async (id: string): Promise => { + requireId(id); + return await withDb(namespace, async (db) => { + const stored = await req(db.transaction(STORE).objectStore(STORE).get(id)); + if (stored === undefined) return undefined; + if (!usableSigningKey(stored)) { + console.warn( + `keystore: the entry ${JSON.stringify(id)} in ${JSON.stringify(namespace)} is not a usable ` + + "non-extractable Ed25519 signing key; discarding it", + ); + await discard(db, id); + return undefined; + } + return new SigningKey(stored, ED25519_ALGORITHM); + }); + }, + }; +} + +/** + * The fragment served when the embedder granted no keystore. Persistence + * is a capability the embedder hands over by naming a namespace, so its + * absence is a refusal the guest can observe and report, never a silent + * no-op that loses keys. + */ +const unavailableKeystore = { + persistSigningKey: (_key: SigningKey, _id: string): Promise => + keystoreError("this embedding grants no keystore"), + loadSigningKey: (_id: string): Promise => + keystoreError("this embedding grants no keystore"), +}; + +/** + * Build the `polymorph:webcrypto-keystore@0.1.0` imports fragment for + * `instantiate`. + * + * Usage: + * `instantiate(artifacts, { ...wasi(), ...webcryptoImports(), ...keystoreImports({ namespace: "pm-device-7" }) })`. + * + * Called without options, the two functions refuse: a guest can ask, and + * learns that this embedding keeps nothing. + * + * Concurrency: two instances sharing a namespace do not corrupt each + * other — every write is one IndexedDB readwrite transaction, which the + * store serializes — and the outcome for an identifier both instances + * store is the later write. Callers that need one winner for a first + * mint settle it above this interface (load, mint on `none`, store), and + * a lost race costs the loser its unstored key, not the store's + * consistency. + */ +export function keystoreImports(options?: KeystoreOptions): Record { + if (options !== undefined && options.namespace === "") { + throw new TypeError("keystoreImports: namespace must not be empty"); + } + return { + "polymorph:webcrypto-keystore/signing-keystore@0.1.0": options === undefined + ? unavailableKeystore + : servedKeystore(options), + }; +} diff --git a/js/polyengine/src/signature.ts b/js/polyengine/src/signature.ts index f47e5e5..7ca4c47 100644 --- a/js/polyengine/src/signature.ts +++ b/js/polyengine/src/signature.ts @@ -214,6 +214,9 @@ export class SigningKey { this.#key = key; this.#algorithm = algorithm; } + get cryptoKey(): CryptoKey { + return this.#key; + } async sign(data: Stream): Promise { const message = await collectByteStream(data); diff --git a/js/polyengine/tests/browser/package-lock.json b/js/polyengine/tests/browser/package-lock.json new file mode 100644 index 0000000..9072307 --- /dev/null +++ b/js/polyengine/tests/browser/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "@polymorph/webcrypto-polyengine-browser-probe", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@polymorph/webcrypto-polyengine-browser-probe", + "dependencies": { + "playwright-core": "1.62.1" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/js/polyengine/tests/browser/package.json b/js/polyengine/tests/browser/package.json new file mode 100644 index 0000000..eeb0ed8 --- /dev/null +++ b/js/polyengine/tests/browser/package.json @@ -0,0 +1,9 @@ +{ + "name": "@polymorph/webcrypto-polyengine-browser-probe", + "private": true, + "type": "module", + "description": "Playwright driver for the keystore browser probe: js/polyengine's polymorph:webcrypto-keystore host module against a real Chromium's IndexedDB + Web Crypto, across a real page reload.", + "dependencies": { + "playwright-core": "1.62.1" + } +} diff --git a/js/polyengine/tests/browser/probe-entry.ts b/js/polyengine/tests/browser/probe-entry.ts new file mode 100644 index 0000000..994ab39 --- /dev/null +++ b/js/polyengine/tests/browser/probe-entry.ts @@ -0,0 +1,241 @@ +// The keystore browser probe's page body: the checks that need a real +// browser — IndexedDB, `CryptoKey` structured clone, and a page reload — +// which the Deno unit suite cannot run (Deno has no IndexedDB). +// +// The steps run through `globalThis.keystoreProbe`, called by +// ../run.mjs's driver from Playwright: it evaluates the pre-reload steps, +// RELOADS the page (a fresh JS realm, fresh module state, one surviving +// IndexedDB origin), and evaluates the post-reload steps. That reload is +// the whole point of the lane — the port's promise is about instances +// that do not share memory. +// +// Every value crossing `page.evaluate` is JSON-safe; key material never +// does. The one public value that crosses is the verification key, hex +// encoded, which is what the driver checks the post-reload signature +// against. +// +// The message signed throughout is a labeled synthetic constant (bytes +// 0,1,2,…), not a captured or realistic-looking value: the checks are +// about key identity, so the message content carries no meaning. + +/// + +import { ed25519Sign, ed25519Verify, SigningKey, SigningKeyOptions } from "../../src/mod.ts"; +import { keystoreImports } from "../../src/keystore.ts"; +import { arrayStream } from "../testStream.ts"; + +/** A labeled synthetic message: byte i = i. Nothing about it is secret or meaningful. */ +const MESSAGE = Uint8Array.from({ length: 32 }, (_, i) => i); + +const KEYSTORE_ID = "polymorph:webcrypto-keystore/signing-keystore@0.1.0"; + +interface Keystore { + persistSigningKey(key: SigningKey, id: string): Promise; + loadSigningKey(id: string): Promise; +} + +function keystore(namespace?: string): Keystore { + const fragment = keystoreImports(namespace === undefined ? undefined : { namespace }); + return fragment[KEYSTORE_ID] as Keystore; +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function mintSigningKey(extractable: boolean): Promise<[SigningKey, string]> { + const options = new SigningKeyOptions(); + options.canSign(true); + options.extractable(extractable); + const [signingKey, verifyingKey] = await ed25519Sign.generateKey(options); + return [signingKey, hex(await verifyingKey.exportKeyRaw())]; +} + +/** The `ComponentException` payload of a refusal, as a string for the driver. */ +function refusal(e: unknown): string { + const payload = (e as { payload?: unknown })?.payload; + return typeof payload === "string" ? payload : String((e as Error)?.message ?? e); +} + +async function expectRefusal(what: string, run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return refusal(e); + } + throw new Error(`${what}: expected a refusal, got success`); +} + +/** Write a value straight into a namespace's object store, bypassing the port. */ +function plant(namespace: string, id: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open(namespace, 1); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("signing-keys")) db.createObjectStore("signing-keys"); + }; + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const tx = db.transaction("signing-keys", "readwrite"); + tx.objectStore("signing-keys").put(value, id); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onabort = tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }; + }); +} + +function rawEntry(namespace: string, id: string): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open(namespace, 1); + open.onupgradeneeded = () => { + const db = open.result; + if (!db.objectStoreNames.contains("signing-keys")) db.createObjectStore("signing-keys"); + }; + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const db = open.result; + const get = db.transaction("signing-keys").objectStore("signing-keys").get(id); + get.onsuccess = () => { + db.close(); + resolve(get.result); + }; + get.onerror = () => { + db.close(); + reject(get.error); + }; + }; + }); +} + +const probe = { + /** Mint a non-extractable signing key, store it, and report its public half. */ + async mintAndPersist(namespace: string, id: string) { + const [key, publicKeyHex] = await mintSigningKey(false); + await keystore(namespace).persistSigningKey(key, id); + return { publicKeyHex, signatureHex: hex(await key.sign(arrayStream(MESSAGE))) }; + }, + + /** + * Load the key stored under `id` and sign the probe message with it — + * the post-reload half of the round trip. The signature is verified + * against `publicKeyHex`, the public half of the key minted BEFORE the + * reload: a different key cannot produce a signature that verifies. + */ + async loadAndSign(namespace: string, id: string, publicKeyHex: string) { + const key = await keystore(namespace).loadSigningKey(id); + if (key === undefined) return { loaded: false }; + const signatureHex = hex(await key.sign(arrayStream(MESSAGE))); + const verifying = await ed25519Verify.importVerifyingKeyRaw( + Uint8Array.from(publicKeyHex.match(/../g) ?? [], (b) => parseInt(b, 16)), + ); + let verified = true; + try { + await verifying.verify(arrayStream(MESSAGE), Uint8Array.from(signatureHex.match(/../g) ?? [], (b) => parseInt(b, 16))); + } catch { + verified = false; + } + return { + loaded: true, + verified, + signatureHex, + extractable: key.extractable(), + canSign: key.canSign(), + algorithm: key.algorithmName(), + }; + }, + + /** An extractable key must be refused at the store edge, and nothing may land. */ + async persistExtractable(namespace: string, id: string) { + const [key] = await mintSigningKey(true); + const message = await expectRefusal( + "persisting an extractable key", + () => keystore(namespace).persistSigningKey(key, id), + ); + return { message, stored: (await rawEntry(namespace, id)) !== undefined }; + }, + + /** A name nothing was stored under is `none`, not an error. */ + async loadMissing(namespace: string, id: string) { + return { loaded: (await keystore(namespace).loadSigningKey(id)) !== undefined }; + }, + + /** Without the embedder's namespace, both functions refuse. */ + async withoutKeystore() { + const [signingKey] = await mintSigningKey(false); + return { + persist: await expectRefusal("persist without a keystore", () => keystore().persistSigningKey(signingKey, "id")), + load: await expectRefusal("load without a keystore", () => keystore().loadSigningKey("id")), + }; + }, + + /** + * An entry that fails the load-side validation is discarded and + * reported as `none`. The planted entry is an EXTRACTABLE key — the + * exact thing the store edge refuses — standing in for any entry + * written by something other than this port, since IndexedDB is + * writable by anything else in the origin. + */ + async plantedExtractable(namespace: string, id: string) { + const pair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]) as CryptoKeyPair; + await plant(namespace, id, pair.privateKey); + const loaded = await keystore(namespace).loadSigningKey(id); + return { loaded: loaded !== undefined, remaining: (await rawEntry(namespace, id)) !== undefined }; + }, + + /** A non-key entry is discarded the same way. */ + async plantedGarbage(namespace: string, id: string) { + await plant(namespace, id, { note: "not a CryptoKey" }); + const loaded = await keystore(namespace).loadSigningKey(id); + return { loaded: loaded !== undefined, remaining: (await rawEntry(namespace, id)) !== undefined }; + }, + + /** Two namespaces are two stores: the same identifier does not collide. */ + async namespaceIsolation(namespaceA: string, namespaceB: string, id: string) { + const [key] = await mintSigningKey(false); + await keystore(namespaceA).persistSigningKey(key, id); + return { + inA: (await keystore(namespaceA).loadSigningKey(id)) !== undefined, + inB: (await keystore(namespaceB).loadSigningKey(id)) !== undefined, + }; + }, + + /** An empty identifier is refused on both functions. */ + async emptyId(namespace: string) { + const [key] = await mintSigningKey(false); + return { + persist: await expectRefusal("persist with an empty id", () => keystore(namespace).persistSigningKey(key, "")), + load: await expectRefusal("load with an empty id", () => keystore(namespace).loadSigningKey("")), + }; + }, + + /** Storing twice under one name converges on the later key (documented last-write-wins). */ + async restoreOverwrites(namespace: string, id: string) { + const [first] = await mintSigningKey(false); + const [second, secondPublicHex] = await mintSigningKey(false); + const store = keystore(namespace); + await store.persistSigningKey(first, id); + await store.persistSigningKey(second, id); + const loaded = await store.loadSigningKey(id); + if (loaded === undefined) return { loaded: false }; + const signatureHex = hex(await loaded.sign(arrayStream(MESSAGE))); + const verifying = await ed25519Verify.importVerifyingKeyRaw( + Uint8Array.from(secondPublicHex.match(/../g) ?? [], (b) => parseInt(b, 16)), + ); + let isSecond = true; + try { + await verifying.verify(arrayStream(MESSAGE), Uint8Array.from(signatureHex.match(/../g) ?? [], (b) => parseInt(b, 16))); + } catch { + isSecond = false; + } + return { loaded: true, isSecond }; + }, +}; + +(globalThis as unknown as { keystoreProbe: typeof probe }).keystoreProbe = probe; diff --git a/js/polyengine/tests/browser/run.mjs b/js/polyengine/tests/browser/run.mjs new file mode 100644 index 0000000..23ff256 --- /dev/null +++ b/js/polyengine/tests/browser/run.mjs @@ -0,0 +1,166 @@ +// The keystore browser probe: js/polyengine's `polymorph:webcrypto-keystore` +// host module driven against a real Chromium, where IndexedDB, the +// `CryptoKey` structured-clone steps, and a page reload all exist. The +// Deno unit suite (tests/families_test.ts) cannot reach any of that — +// Deno has no IndexedDB — so this is the lane that observes the module's +// actual promise: a key stored by one page is signed with by the next. +// +// Run it with `just polyengine-keystore-probe`, which builds the page bundle +// first (deno bundle --platform browser, the same tool the polyengine-browser +// conformance leg uses). Needs a Chromium: Playwright's pinned build +// (`npx playwright-core install chromium`) or a system Chrome, located +// the same way the conformance browser legs locate one. +// +// The page is served from memory over a loopback origin on an EPHEMERAL +// port — IndexedDB needs a real origin, and a fixed port would collide +// with a sibling checkout's probe and silently measure the wrong tree. +// The bytes served are the bytes just read from the bundle, so there is +// no build-identity question to get wrong. + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import process from "node:process"; + +import { chromium } from "playwright-core"; + +const BUNDLE = fileURLToPath( + new URL("../../../../target/polyengine-keystore-probe/probe.js", import.meta.url), +); + +const PAGE = ` + +polymorph:webcrypto-keystore probe + +`; + +async function serve(bundle) { + const server = createServer((req, res) => { + if (req.url === "/probe.js") { + res.writeHead(200, { "content-type": "text/javascript" }); + res.end(bundle); + return; + } + res.writeHead(200, { "content-type": "text/html" }); + res.end(PAGE); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { server, origin: `http://127.0.0.1:${server.address().port}/` }; +} + +const results = []; +function check(name, ok, detail) { + results.push({ name, ok, detail }); + console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +} + +/** The page's module is loaded when the probe object is on the window. */ +async function waitForProbe(page) { + await page.waitForFunction(() => globalThis.keystoreProbe !== undefined, null, { timeout: 30_000 }); +} + +const bundle = await readFile(BUNDLE, "utf8"); +if (!bundle.includes("keystoreProbe")) { + console.error(`the bundle at ${BUNDLE} does not define the probe; rebuild it`); + process.exit(1); +} + +const { server, origin } = await serve(bundle); +const browser = await chromium.launch(); +try { + const context = await browser.newContext(); + const page = await context.newPage(); + page.on("pageerror", (e) => console.error("page error:", e.message)); + await page.goto(origin); + await waitForProbe(page); + + // The round trip's first half: mint a non-extractable key and store it. + const minted = await page.evaluate(() => globalThis.keystoreProbe.mintAndPersist("pm-probe-roundtrip", "identity")); + + // A real reload: new realm, new module instances, nothing in memory + // survives. Only the origin's IndexedDB does. + await page.reload(); + await waitForProbe(page); + + const loaded = await page.evaluate( + (publicKeyHex) => globalThis.keystoreProbe.loadAndSign("pm-probe-roundtrip", "identity", publicKeyHex), + minted.publicKeyHex, + ); + check( + "persist, reload, load, sign: the loaded key is the key that was stored", + loaded.loaded === true && loaded.verified === true, + `loaded=${loaded.loaded} verified=${loaded.verified}`, + ); + check( + "the loaded key reports the policy it was minted with", + loaded.extractable === false && loaded.canSign === true && loaded.algorithm === "Ed25519", + `extractable=${loaded.extractable} canSign=${loaded.canSign} algorithm=${loaded.algorithm}`, + ); + + const extractable = await page.evaluate(() => + globalThis.keystoreProbe.persistExtractable("pm-probe-extractable", "identity") + ); + check( + "an extractable signing key is refused at persist, and nothing is stored", + extractable.stored === false && extractable.message.includes("extractable"), + `stored=${extractable.stored} refusal=${JSON.stringify(extractable.message)}`, + ); + + const missing = await page.evaluate(() => globalThis.keystoreProbe.loadMissing("pm-probe-roundtrip", "no-such-id")); + check("an identifier nothing was stored under loads as none", missing.loaded === false, `loaded=${missing.loaded}`); + + const ungranted = await page.evaluate(() => globalThis.keystoreProbe.withoutKeystore()); + check( + "without an embedder-granted namespace, both functions refuse", + ungranted.persist.includes("no keystore") && ungranted.load.includes("no keystore"), + `persist=${JSON.stringify(ungranted.persist)} load=${JSON.stringify(ungranted.load)}`, + ); + + const planted = await page.evaluate(() => + globalThis.keystoreProbe.plantedExtractable("pm-probe-planted", "identity") + ); + check( + "a stored key reporting extractable is not returned, and is discarded", + planted.loaded === false && planted.remaining === false, + `loaded=${planted.loaded} remaining=${planted.remaining}`, + ); + + const garbage = await page.evaluate(() => globalThis.keystoreProbe.plantedGarbage("pm-probe-garbage", "identity")); + check( + "a stored entry that is not a key is not returned, and is discarded", + garbage.loaded === false && garbage.remaining === false, + `loaded=${garbage.loaded} remaining=${garbage.remaining}`, + ); + + const isolation = await page.evaluate(() => + globalThis.keystoreProbe.namespaceIsolation("pm-probe-ns-a", "pm-probe-ns-b", "identity") + ); + check( + "namespaces are separate stores: one identifier, two answers", + isolation.inA === true && isolation.inB === false, + `inA=${isolation.inA} inB=${isolation.inB}`, + ); + + const empty = await page.evaluate(() => globalThis.keystoreProbe.emptyId("pm-probe-roundtrip")); + check( + "an empty identifier is refused on both functions", + empty.persist.includes("empty") && empty.load.includes("empty"), + `persist=${JSON.stringify(empty.persist)} load=${JSON.stringify(empty.load)}`, + ); + + const overwrite = await page.evaluate(() => + globalThis.keystoreProbe.restoreOverwrites("pm-probe-overwrite", "identity") + ); + check( + "storing twice under one identifier converges on the later key", + overwrite.loaded === true && overwrite.isSecond === true, + `loaded=${overwrite.loaded} isSecond=${overwrite.isSecond}`, + ); +} finally { + await browser.close(); + server.close(); +} + +const failed = results.filter((r) => !r.ok); +console.log(`\n${results.length - failed.length}/${results.length} checks passed`); +process.exit(failed.length === 0 ? 0 : 1); diff --git a/justfile b/justfile index 5266751..d3f2ca2 100644 --- a/justfile +++ b/justfile @@ -43,6 +43,22 @@ check: fmt-check clippy validate-wit test polyengine-module-check: cd js/polyengine && deno task check && deno task test +# The keystore host module's browser lane: js/polyengine's +# `polymorph:webcrypto-keystore` port against a real Chromium's IndexedDB, +# `CryptoKey` structured clone, and a page reload — none of which exist +# under Deno, so the unit suite cannot observe the module's promise at +# all. Needs a Chromium (Playwright's pinned build or a system Chrome) and +# one npm install in the probe's tree. +polyengine-keystore-probe: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p target/polyengine-keystore-probe + (cd js/polyengine && deno bundle --config deno.json --frozen --platform browser \ + -o ../../target/polyengine-keystore-probe/probe.js tests/browser/probe-entry.ts) + cd js/polyengine/tests/browser + if [ ! -d node_modules ]; then npm install --no-audit --no-fund; fi + node run.mjs + # Check formatting across all crates. fmt-check: cargo fmt --all -- --check @@ -72,6 +88,10 @@ validate-wit: # with every feature enabled. wasm-tools component wit wit wasm-tools component wit wit --all-features + # The keystore sibling package, which imports the signing-key + # resource from the package above. + wasm-tools component wit wit-keystore + wasm-tools component wit wit-keystore --all-features wasm-tools component wit rust/wasmtime/wit wasm-tools component wit rust/wasmtime/wit --all-features wasm-tools component wit js/jco/wit diff --git a/wit-keystore/README.md b/wit-keystore/README.md new file mode 100644 index 0000000..8b05529 --- /dev/null +++ b/wit-keystore/README.md @@ -0,0 +1,60 @@ +# `wit-keystore` — the `polymorph:webcrypto-keystore` package + +A **sibling** package to [`polymorph:webcrypto`](../wit): keeping a +signing key across instantiations, by name, without the key's material +ever crossing an interface. + +It lives beside that package rather than inside it because +`polymorph:webcrypto` tracks the Web Cryptography API and takes no +extensions to it (`AGENTS.md`, "What this repository is"). Storing a key +by a name of the guest's choosing is not a SubtleCrypto operation: what +WebCrypto supplies is the enabling machinery — §13's structured-clone +steps for `CryptoKey`, which exist so IndexedDB can hold one. The ruling +and the inherited design record are +[issue #389](https://github.com/polymorph-components/polymorph-webcrypto/issues/389) +(successor to #97). + +The package pulls `polymorph:webcrypto` in through the +`deps/polymorph-webcrypto` **symlink** back to the root `wit/`, the same +way every component in this repository does. Do not replace it with a +copy. + +## The shape + +One interface, `signing-keystore`, with two functions: store a +`polymorph:webcrypto/signature.signing-key` under an identifier, and load +it back. `none` from a load means "nothing usable under that name" — the +ordinary first-run answer, and also what an entry that fails validation +reports, because such an entry is removed rather than returned. + +Two properties do the real work, and both are stated in the WIT: + +- **Placement is the host's, naming is the guest's.** The host is + configured with one storage root before the guest runs; guest + identifiers are scoped inside it. A guest cannot reach another's keys + and cannot choose where its own live. +- **A loaded key is untrusted input.** An implementation validates a + stored entry — algorithm, key type, usages, `extractable` — against the + resource type being loaded, and returns nothing that fails. The store + is typically writable by anything else in the same protection domain. + +## Errors + +`result<_, string>`: the string describes a condition (no keystore +configured, the store is unavailable, the name is empty, the key is +extractable), not a code to branch on. The package deliberately does not +reuse `polymorph:webcrypto/types.error` — that variant's cases are the +crypto-operation conditions its contracts named, and it is frozen against +growth (`AGENTS.md`, "WIT is organized by ownership"), so borrowing it +here would either misreport storage conditions as `other` or push a +foreign package's needs onto a closed variant. + +## Implementations + +`js/polyengine/src/keystore.ts` (`@polymorph/webcrypto/keystore`) serves it +over IndexedDB, one database per namespace. Its browser lane is `just +polyengine-keystore-probe`. A native analogue (preopen-backed key files) is +open design, and the interface does not preclude one; see #389's open +questions, which also carry the two other undecided points — kind +coverage beyond `signing-key`, and whether a guest may enumerate its +namespace. diff --git a/wit-keystore/deps/polymorph-webcrypto b/wit-keystore/deps/polymorph-webcrypto new file mode 120000 index 0000000..902b548 --- /dev/null +++ b/wit-keystore/deps/polymorph-webcrypto @@ -0,0 +1 @@ +../../wit \ No newline at end of file diff --git a/wit-keystore/keystore.wit b/wit-keystore/keystore.wit new file mode 100644 index 0000000..b54c8c8 --- /dev/null +++ b/wit-keystore/keystore.wit @@ -0,0 +1,73 @@ +package polymorph:webcrypto-keystore@0.1.0; + +/// Keeping a signing key across instantiations, without ever holding its +/// material. +/// +/// A guest that mints a signing key loses it when the instance goes away. +/// This interface gives the guest a durable place to put the key's +/// *handle*: the key is stored by a name the guest chooses, and a later +/// instance asks for the same name and receives a key resource that signs +/// exactly as the original did. No key material crosses the interface in +/// either direction, so a non-extractable key stays non-extractable +/// forever — which is the point. The alternative, exporting material and +/// re-importing it next time, requires an extractable key and therefore a +/// copy of the private key in guest memory. +/// +/// Placement is the host's, naming is the guest's. The host is configured +/// with one storage root (which store, which device, which profile) before +/// the guest runs; every name the guest uses is scoped inside that root. +/// A guest therefore cannot reach another guest's keys, and cannot choose +/// where its own are kept. +/// +/// Security: +/// - A loaded key is a key the caller did not mint, so its policy is not +/// the caller's to assume: interrogate it. `signing-key.extractable` +/// and `signing-key.can-sign` answer for a loaded key exactly as they +/// do for a minted one, and the operations that its policy denies fail +/// with `polymorph:webcrypto/types.error.not-permitted` as usual. +/// - Storage is not a trust boundary. An implementation MUST validate a +/// stored entry against the resource type being loaded — algorithm, +/// permitted operations, and extractability — and MUST NOT return a key +/// that fails validation. A store is typically writable by anything +/// else running in the same protection domain (a browser origin, a user +/// account), so an entry is untrusted input. +/// - This interface does not encrypt anything. It offers the durability +/// and the confidentiality the host's store offers, no more. Where the +/// store rests in the clear, so do the entries; what a non-extractable +/// handle buys is that the material cannot be *read out* through this +/// API, not that the storage is protected. +interface signing-keystore { + use polymorph:webcrypto/signature@0.1.0.{signing-key}; + + /// Store `key` under the name `id`, inside the host's configured + /// storage root. + /// + /// The operation is idempotent under `id`: storing again with the + /// same name replaces the entry, and a cancelled call leaves the + /// keystore in a state a plain retry converges from. `id` must not be + /// empty. `key` is borrowed: the caller keeps using it afterwards. + /// + /// Security: + /// - An extractable key is refused. What this interface promises a + /// later instance is a key whose material was never readable, and + /// an extractable key cannot carry that promise. Mint with + /// `extractable` left off to store. + /// + /// The error is a human-readable description of a *condition*, not a + /// code to branch on: no keystore is configured, the store is + /// unavailable (some private-browsing modes offer none), the name is + /// empty, or the key is extractable. + persist-signing-key: async func(key: borrow, id: string) -> result<_, string>; + + /// Load the signing key stored under `id`, or `none` if this storage + /// root holds no usable key by that name. + /// + /// `none` is the ordinary "first run here" answer, and it is also + /// what an entry that fails validation reports: such an entry is + /// removed, so the caller's mint-and-store path runs and the bad + /// entry does not come back. An error means the keystore itself could + /// not be consulted — no keystore configured, or the store is + /// unavailable — which is a different situation from "nothing stored + /// under that name" and callers usually treat it differently. + load-signing-key: async func(id: string) -> result, string>; +}