diff --git a/js/polyengine/src/ecdsa.ts b/js/polyengine/src/ecdsa.ts index 8b3732d..13bbc4c 100644 --- a/js/polyengine/src/ecdsa.ts +++ b/js/polyengine/src/ecdsa.ts @@ -26,6 +26,7 @@ import { } from "./signature.ts"; import { requireEcdsaJwkAlg, requireEcJwkCurve, requireOnCurveSec1, requireOnCurveSpki } from "./ec.ts"; import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; +import { MINT } from "./internal.ts"; import { unwrappedJwk } from "./util.ts"; const subtle = globalThis.crypto.subtle; @@ -73,7 +74,7 @@ export const ecdsaVerify = { true, ["verify"], ); - return new VerifyingKey(key, entry); + return new VerifyingKey(MINT, key, entry); }, importVerifyingKeySpki: async (variant: string, spki: Uint8Array): Promise => { @@ -88,7 +89,7 @@ export const ecdsaVerify = { true, ["verify"], ); - return new VerifyingKey(key, entry); + return new VerifyingKey(MINT, key, entry); }, importVerifyingKeyJwk: async (variant: string, jwkText: string): Promise => { @@ -103,7 +104,7 @@ export const ecdsaVerify = { true, ["verify"], ); - return new VerifyingKey(key, entry); + return new VerifyingKey(MINT, key, entry); }, }; @@ -119,7 +120,7 @@ export const ecdsaSign = { policy.extractable, ["sign", "verify"], )) as CryptoKeyPair; - return [new SigningKey(pair.privateKey, entry), new VerifyingKey(pair.publicKey, entry)]; + return [new SigningKey(MINT, pair.privateKey, entry), new VerifyingKey(MINT, pair.publicKey, entry)]; }, importSigningKeyPkcs8: async ( @@ -138,7 +139,7 @@ export const ecdsaSign = { policy.extractable, ["sign"], ); - return new SigningKey(key, entry); + return new SigningKey(MINT, key, entry); }, importSigningKeyJwk: async (variant: string, jwkText: string, options: SigningKeyOptions): Promise => { @@ -157,7 +158,7 @@ export const ecdsaSign = { ["sign"], ); if (key.type !== "private") errInvalidKey("EC private JWK must carry `d` (base64url private scalar)"); - return new SigningKey(key, entry); + return new SigningKey(MINT, key, entry); }, unwrapSigningKeyPkcs8: (variant: string, input: UnwrapInput, options: SigningKeyOptions): Promise => { diff --git a/js/polyengine/src/hkdf.ts b/js/polyengine/src/hkdf.ts index 542bf37..6cbc87e 100644 --- a/js/polyengine/src/hkdf.ts +++ b/js/polyengine/src/hkdf.ts @@ -1,6 +1,6 @@ // `polymorph:webcrypto/hkdf` + `hkdf-sha2` + `hkdf-sha1` — wit/hkdf.wit. -import { errOther, errUnsupported, notPermitted, platformCall } from "./errors.ts"; +import { errInvalidKey, errNotPermitted, errOther, errUnsupported, notPermitted, platformCall } from "./errors.ts"; import { DeriveInput, type DerivePolicy, @@ -17,7 +17,14 @@ const subtle = globalThis.crypto.subtle; const ikmState = new WeakMap(); -/** `hkdf.ikm`: input keying material, consumable only by `prepare`. */ +/** + * `hkdf.ikm`: input keying material, consumable only by `prepare`. + * + * The constructor is effectively internal already — all state lives in the + * module-private `ikmState` WeakMap, so a bare `new Ikm()` yields an object + * every method refuses. The supported external construction path is + * {@link Ikm.fromCryptoKey} (polymorph-webcrypto#391). + */ export class Ikm { canDeriveBits(): boolean { return ikmState.get(this)!.policy.deriveBits; @@ -25,6 +32,82 @@ export class Ikm { canDeriveKey(): boolean { return ikmState.get(this)!.policy.deriveKey; } + + /** + * Adopt an embedder-held HKDF `CryptoKey` — the injection half of the + * persistence seam (polymorph-webcrypto#391): an embedder that keeps its + * input keying material as a NON-EXTRACTABLE `CryptoKey` in IndexedDB gets + * it back as an `ikm` here, instead of having to hold the raw bytes. + * + * Synchronous, and validating: a platform `CryptoKey`, of type `secret`, + * with `algorithm.name === "HKDF"`. + * + * The derive policy is READ OFF THE PLATFORM USAGES rather than taken from a + * `derive-options` — for an injected key the platform's `deriveBits` / + * `deriveKey` slots ARE the policy, since loading is itself a minting path + * and the platform will refuse anything the slots do not cover regardless of + * what this wrapper claimed. A key with neither derive usage is a degenerate + * injection and is refused (`not-permitted`), the same rule as an options + * resource granting nothing (derivation.ts:50-52). + * + * Validation and storage both use a LAUNDERED clone (see + * signature.ts's `launderCryptoKey` for the reasoning): `usages` and + * `algorithm` are shadowable own-property accessors on the caller's object, + * and structured clone carries only the internal slots, so `canDeriveBits()` + * answers platform truth and no caller retains a handle to the key this + * `ikm` derives with. + */ + static fromCryptoKey(key: CryptoKey): Ikm { + const what = "ikm injection"; + if (!(key instanceof CryptoKey)) errInvalidKey(`${what} takes a platform CryptoKey`); + let clone: CryptoKey; + try { + clone = structuredClone(key); + } catch { + errUnsupported( + `${what}: this host does not serialize CryptoKey (structured clone), which key injection requires`, + ); + } + if (clone.type !== "secret") { + errInvalidKey(`${what} takes a secret key, got a ${clone.type} key`); + } + if (clone.algorithm.name !== "HKDF") { + errInvalidKey(`${what} takes an HKDF key, got ${clone.algorithm.name}`); + } + const policy: DerivePolicy = { + deriveBits: clone.usages.includes("deriveBits"), + deriveKey: clone.usages.includes("deriveKey"), + }; + if (!policy.deriveBits && !policy.deriveKey) { + errNotPermitted("an ikm permitting neither derive-bits nor derive-key cannot be injected"); + } + return mintIkm(clone, policy); + } + + /** + * Hand back the platform key — the extraction half of the persistence seam + * (polymorph-webcrypto#391). The returned `CryptoKey` is structured-clonable + * into IndexedDB with its non-extractability preserved, which is how keying + * material is meant to outlive a session. + * + * Security framing, as on `signing-key`: material confidentiality belongs to + * the `extractable` bit and stays platform-enforced in both directions — + * `hkdf.import-ikm` mints non-extractable and this hands back a key, not + * bytes. What the wrapper scopes is the USE capability in durable, + * parameter-free form: a raw HKDF `CryptoKey` derives under any salt/info/ + * hash its holder picks, whereas an `ikm` is consumable only through + * `prepare` under the policy above. Returning a FRESH CLONE per call keeps + * the wrapper's own key unreachable, so that scoping is total. + * + * Inverse of {@link Ikm.fromCryptoKey}: the returned key satisfies that + * validation by construction (the derive policy round-trips through the + * platform usages). + */ + toCryptoKey(): CryptoKey { + const state = ikmState.get(this); + if (state === undefined) errOther("ikm minted by another provider"); + return structuredClone(state.key); + } } function mintIkm(key: CryptoKey, policy: DerivePolicy): Ikm { diff --git a/js/polyengine/src/internal.ts b/js/polyengine/src/internal.ts new file mode 100644 index 0000000..9dbfba3 --- /dev/null +++ b/js/polyengine/src/internal.ts @@ -0,0 +1,15 @@ +// Module-private construction token for the resource classes whose +// constructors are runtime-internal (polymorph-webcrypto#391). +// +// This module is deliberately NOT re-exported from mod.ts: `deno.json`'s +// single `exports` entry (`./src/mod.ts`) is what a consumer can reach, so +// keeping `MINT` out of mod.ts makes the token unforgeable from outside the +// package rather than merely undocumented. +// +// `Symbol()` and not `Symbol.for()`: a registry symbol is reachable by name +// from any realm-sharing code, which would hand the token to exactly the +// callers the constructor guard exists to refuse. Module-private IDENTITY is +// the whole mechanism. + +/** The witness that a `signing-key`/`verifying-key` came out of a minting interface in this package. */ +export const MINT: unique symbol = Symbol("polymorph:webcrypto mint"); diff --git a/js/polyengine/src/rsaSignature.ts b/js/polyengine/src/rsaSignature.ts index 9e42c7e..3016106 100644 --- a/js/polyengine/src/rsaSignature.ts +++ b/js/polyengine/src/rsaSignature.ts @@ -30,6 +30,7 @@ import { VerifyingKey, } from "./signature.ts"; import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; +import { MINT } from "./internal.ts"; import { unwrappedJwk } from "./util.ts"; const subtle = globalThis.crypto.subtle; @@ -137,7 +138,7 @@ async function importRsaVerifyingKeySpki( requireRsaEncryptionSpki(spki); const key = await importPlatformKey(`${name} spki`, "spki", spki, { name, hash }, true, ["verify"]); const modulusLength = rsaAdmittedModulusLength(key, `${name} spki`); - return new VerifyingKey(key, rsaAlgorithm(name, hash, modulusLength, saltLength)); + return new VerifyingKey(MINT, key, rsaAlgorithm(name, hash, modulusLength, saltLength)); } /** @@ -176,7 +177,7 @@ async function importRsaVerifyingKeyJwk( requireStrictBase64url(jwk.e); const key = await importPlatformKeyJwk(`${name} public JWK`, jwk, { name, hash }, true, ["verify"]); const modulusLength = rsaAdmittedModulusLength(key, `${name} public JWK`); - return new VerifyingKey(key, rsaAlgorithm(name, hash, modulusLength, saltLength)); + return new VerifyingKey(MINT, key, rsaAlgorithm(name, hash, modulusLength, saltLength)); } /** The `polymorph:webcrypto/rsassa-pkcs1-v15-verify@0.1.0` interface. */ @@ -213,7 +214,7 @@ async function generateRsaSigningKey( ["sign", "verify"], )) as CryptoKeyPair; const algorithm = rsaSigningAlgorithm(name, entry, modulusLength); - return [new SigningKey(pair.privateKey, algorithm), new VerifyingKey(pair.publicKey, algorithm)]; + return [new SigningKey(MINT, pair.privateKey, algorithm), new VerifyingKey(MINT, pair.publicKey, algorithm)]; } async function importRsaSigningKeyPkcs8( @@ -235,7 +236,7 @@ async function importRsaSigningKeyPkcs8( ["sign"], ); const modulusLength = rsaAdmittedModulusLength(key, `${name} pkcs8`, RSA_SIGNING_MIN_BITS, RSA_SIGNING_MAX_BITS); - return new SigningKey(key, rsaSigningAlgorithm(name, entry, modulusLength)); + return new SigningKey(MINT, key, rsaSigningAlgorithm(name, entry, modulusLength)); } async function importRsaSigningKeyJwk( @@ -267,7 +268,7 @@ async function importRsaSigningKeyJwk( RSA_SIGNING_MIN_BITS, RSA_SIGNING_MAX_BITS, ); - return new SigningKey(key, rsaSigningAlgorithm(name, entry, modulusLength)); + return new SigningKey(MINT, key, rsaSigningAlgorithm(name, entry, modulusLength)); } /** The minting-object shape returned by `rsaSigningInterface` for one RSA signing scheme. */ diff --git a/js/polyengine/src/signature.ts b/js/polyengine/src/signature.ts index f47e5e5..0aaa1e4 100644 --- a/js/polyengine/src/signature.ts +++ b/js/polyengine/src/signature.ts @@ -32,6 +32,7 @@ import { import { b64urlDecode } from "./platform.ts"; import { consumeUnwrapInput, type UnwrapInput, WrapInput } from "./wrapping.ts"; import { errOther } from "./errors.ts"; +import { MINT } from "./internal.ts"; import type { Stream } from "@polyengine/runtime/embedder"; const subtle = globalThis.crypto.subtle; @@ -131,17 +132,143 @@ function ed25519PointStrict(encoded: Uint8Array): boolean { return !ED25519_SMALL_ORDER_Y.some((torsion) => bytesEqual(y, torsion)); } +// --- The embedder key seams (polymorph-webcrypto#391). +// +// MODULE INVARIANT (stated once, for every key class in this file): the +// `CryptoKey` behind a live wrapper is reachable by no code outside this +// module, and every policy answer a wrapper gives — `canSign`, +// `extractable`, the algorithm getters — is computed either from the +// mint-bound ALGORITHM RECORD (the file-header authority) or from the +// platform-verified internal slots of a LAUNDERED clone. Injection +// (`fromCryptoKey`) and extraction (`toCryptoKey`) both cross that boundary +// by value, never by reference, so the invariant survives both directions. +// +// Why these seams exist, defensively: without them an embedder that wants a +// signing key to SURVIVE A SESSION has to hold the material in extractable +// form (export the JWK, stash the bytes, re-import). The persistence path +// WebCrypto already blesses — structured-clone a non-extractable `CryptoKey` +// into IndexedDB — was unreachable through this package because the platform +// key never came back out. `toCryptoKey` opens it; `fromCryptoKey` closes the +// loop and refuses the degenerate injections on the way back in. + +/** + * Launder an embedder-supplied `CryptoKey` into a clone this module owns. + * + * `CryptoKey`'s internal slots are immutable, but its PROTOTYPE GETTERS are + * shadowable — `Object.defineProperty(key, "usages", { value: [...] })` makes + * `key.usages` say whatever the caller likes. Structured clone serializes the + * internal slots only and drops own properties, so the clone answers with + * platform truth; validating and storing the CLONE (never the argument) is + * what makes every downstream policy mirror trustworthy. Storing a clone also + * denies the caller a live handle to the wrapper's key. + */ +function launderCryptoKey(what: string, key: CryptoKey): CryptoKey { + try { + return structuredClone(key); + } catch { + // Not a taxonomy fudge: on a host whose structured-clone algorithm does + // not serialize `CryptoKey` (the WebCrypto-spec behaviour is optional in + // practice), injecting a host-held key is a well-formed request this + // implementation cannot serve. + errUnsupported( + `${what}: this host does not serialize CryptoKey (structured clone), which key injection requires`, + ); + } +} + +/** The shape gate shared by every `fromCryptoKey`: a real platform key, not a duck-typed stand-in. */ +function requirePlatformKey(what: string, key: CryptoKey): void { + if (!(key instanceof CryptoKey)) { + errInvalidKey(`${what} takes a platform CryptoKey`); + } +} + +/** + * The v1 family boundary for key injection: Ed25519 only. + * + * Every other signature family carries a mint binding the PLATFORM KEY DOES + * NOT RECORD — ECDSA's per-mint digest, RSA-PSS's salt length (see + * `SignatureAlgorithm` above, and the file header on why `CryptoKey.algorithm` + * is never the authority). Injecting one would mean inventing those bindings + * or reading them off the untrusted key; both are refused. Admitting those + * families needs an explicit bindings parameter, which waits for a consumer. + */ +function requireEd25519Injection(what: string, algorithmName: string): void { + if (algorithmName !== "Ed25519") { + errUnsupported( + `${what} serves Ed25519 only: a ${algorithmName} key's mint bindings ` + + "(the ECDSA per-mint hash, the RSA-PSS salt length) are not carried by a CryptoKey, " + + "so injecting one would have to invent them", + ); + } +} + /** `signature.verifying-key`: a public key, secret-free. */ export class VerifyingKey { #key: CryptoKey; #algorithm: SignatureAlgorithm; - constructor(key: CryptoKey, algorithm: SignatureAlgorithm) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `verifying-key` exists only + * as minted by an import/generate interface in this package or by + * {@link VerifyingKey.fromCryptoKey}, because the algorithm record passed + * here is the security authority for every later check. `MINT` is + * module-private and unexported from mod.ts, so no external caller can + * reach this signature. + */ + constructor(token: typeof MINT, key: CryptoKey, algorithm: SignatureAlgorithm) { + if (token !== MINT) errOther("verifying-key constructed outside its minting interfaces"); this.#key = key; this.#algorithm = algorithm; } - get cryptoKey(): CryptoKey { - return this.#key; + + /** + * Adopt an embedder-held public `CryptoKey` — the injection half of the + * persistence seam (polymorph-webcrypto#391): the key an embedder + * structured-cloned out of IndexedDB comes back as a wrapper here. + * + * Synchronous, and validating: the key must be a platform `CryptoKey`, of + * type `public`, of the Ed25519 family (see {@link requireEd25519Injection} + * for the v1 boundary), and must permit `verify` — a verifying key that + * cannot verify is a degenerate injection and is refused loudly rather than + * minted into a resource that will only fail later. Validation reads the + * LAUNDERED clone, so shadowed accessors on the argument cannot talk their + * way past any of it. + */ + static fromCryptoKey(key: CryptoKey): VerifyingKey { + const what = "verifying-key injection"; + requirePlatformKey(what, key); + const clone = launderCryptoKey(what, key); + if (clone.type !== "public") { + errInvalidKey(`${what} takes a public key, got a ${clone.type} key`); + } + requireEd25519Injection(what, clone.algorithm.name); + if (!clone.usages.includes("verify")) notPermitted("verify"); + return new VerifyingKey(MINT, clone, ED25519_ALGORITHM); + } + + /** + * Hand back the platform key — the extraction half of the persistence seam + * (polymorph-webcrypto#391). The returned `CryptoKey` is structured-clonable + * straight into IndexedDB, NON-EXTRACTABILITY PRESERVED, which is the whole + * point: an embedder persists the key without ever holding its material. + * + * Security framing. Material confidentiality is entirely the `extractable` + * bit's job, and the platform enforces it in both directions — extraction + * here neither grants nor weakens it. What the wrapper's seal scopes is the + * USE capability in durable, parameter-free form: a raw `CryptoKey` verifies + * under any parameters a caller chooses and travels across sessions, whereas + * a wrapper is mint-bound (the algorithm record above) and realm-confined. + * Returning a FRESH CLONE per call, never `#key`, keeps the wrapper's own + * key unreachable, so that invariant stays total: nothing a caller does to + * the returned object can be observed by the wrapper. + * + * Extraction and injection are inverses: this key round-trips through + * {@link VerifyingKey.fromCryptoKey}, whose validation it satisfies by + * construction. + */ + toCryptoKey(): CryptoKey { + return launderCryptoKey("verifying-key extraction", this.#key); } /** @@ -210,11 +337,70 @@ export class SigningKey { #key: CryptoKey; #algorithm: SignatureAlgorithm; - constructor(key: CryptoKey, algorithm: SignatureAlgorithm) { + /** + * Runtime-internal (polymorph-webcrypto#391) — see + * {@link VerifyingKey}'s constructor: the algorithm record is the authority + * for the per-operation parameters, so it may only be bound by a minting + * interface in this module or by {@link SigningKey.fromCryptoKey}. + */ + constructor(token: typeof MINT, key: CryptoKey, algorithm: SignatureAlgorithm) { + if (token !== MINT) errOther("signing-key constructed outside its minting interfaces"); this.#key = key; this.#algorithm = algorithm; } + /** + * Adopt an embedder-held private `CryptoKey` — the injection half of the + * persistence seam (polymorph-webcrypto#391), the path that lets an embedder + * keep a NON-EXTRACTABLE signing key across sessions instead of falling back + * to an extractable-material posture. + * + * Synchronous, and validating: a platform `CryptoKey`, of type `private`, of + * the Ed25519 family (see {@link requireEd25519Injection}), permitting + * `sign`. The usage check mirrors the mint rule that an untouched options + * resource cannot mint (derivation.ts:50-52): a signing key that cannot sign + * is a degenerate injection, refused here rather than at first use. + * + * Validation reads the LAUNDERED clone (see {@link launderCryptoKey}), which + * is also what the wrapper stores — a caller cannot shadow `type`, `usages` + * or `algorithm` on the argument to get a key admitted, and cannot retain a + * live handle to the key the wrapper signs with. + */ + static fromCryptoKey(key: CryptoKey): SigningKey { + const what = "signing-key injection"; + requirePlatformKey(what, key); + const clone = launderCryptoKey(what, key); + if (clone.type !== "private") { + errInvalidKey(`${what} takes a private key, got a ${clone.type} key`); + } + requireEd25519Injection(what, clone.algorithm.name); + if (!clone.usages.includes("sign")) notPermitted("sign"); + return new SigningKey(MINT, clone, ED25519_ALGORITHM); + } + + /** + * Hand back the platform key — the extraction half of the persistence seam + * (polymorph-webcrypto#391). The returned `CryptoKey` structured-clones into + * IndexedDB with its non-extractability intact; that, and not material + * export, is how a private key is meant to be persisted. + * + * Security framing (the same one stated on + * {@link VerifyingKey.toCryptoKey}): confidentiality of the material is the + * `extractable` bit's job and stays platform-enforced — this method hands + * back a key, never bytes, and a non-extractable key remains non-extractable + * in the caller's hands. What the wrapper seals is the USE capability in + * durable, parameter-free form: a raw `CryptoKey` signs under whatever + * parameters its holder picks and survives across sessions; a wrapper is + * mint-bound and realm-confined. A fresh clone per call keeps `#key` + * unreachable, so that scoping is total rather than best-effort. + * + * Inverse of {@link SigningKey.fromCryptoKey}: the returned key satisfies + * that validation by construction. + */ + toCryptoKey(): CryptoKey { + return launderCryptoKey("signing-key extraction", this.#key); + } + async sign(data: Stream): Promise { const message = await collectByteStream(data); if (!this.canSign()) notPermitted("sign"); @@ -344,13 +530,13 @@ export const ed25519Verify = { if (raw.length !== 32) errInvalidKey(`Ed25519 public keys are 32 bytes, got ${raw.length}`); if (!ed25519PointStrict(raw)) errInvalidKey("non-canonical or small-order Ed25519 public key"); const key = await importPlatformKey("Ed25519 public key", "raw", raw, "Ed25519", true, ["verify"]); - return new VerifyingKey(key, ED25519_ALGORITHM); + return new VerifyingKey(MINT, key, ED25519_ALGORITHM); }, importVerifyingKeySpki: async (spki: Uint8Array): Promise => { const point = rfc8410SpkiKey(0x70, spki, "Ed25519"); if (!ed25519PointStrict(point)) errInvalidKey("non-canonical or small-order Ed25519 public key"); const key = await importPlatformKey("Ed25519 spki", "spki", spki, "Ed25519", true, ["verify"]); - return new VerifyingKey(key, ED25519_ALGORITHM); + return new VerifyingKey(MINT, key, ED25519_ALGORITHM); }, importVerifyingKeyJwk: async (jwkText: string): Promise => { const jwk = jwkMaterial(jwkText); @@ -360,7 +546,7 @@ export const ed25519Verify = { errInvalidKey("non-canonical or small-order Ed25519 public key"); } const key = await importPlatformKeyJwk("Ed25519 public JWK", jwk, "Ed25519", true, ["verify"]); - return new VerifyingKey(key, ED25519_ALGORITHM); + return new VerifyingKey(MINT, key, ED25519_ALGORITHM); }, }; @@ -376,13 +562,13 @@ export const ed25519Sign = { // secret-free and always usable). const pair = await platformCall("Ed25519 key generation", () => subtle.generateKey("Ed25519", policy.extractable, ["sign", "verify"])) as CryptoKeyPair; - return [new SigningKey(pair.privateKey, ED25519_ALGORITHM), new VerifyingKey(pair.publicKey, ED25519_ALGORITHM)]; + return [new SigningKey(MINT, pair.privateKey, ED25519_ALGORITHM), new VerifyingKey(MINT, pair.publicKey, ED25519_ALGORITHM)]; }, importSigningKeyPkcs8: async (pkcs8: Uint8Array, options: SigningKeyOptions): Promise => { const policy = signingPolicyOf(options); requireSigningGrant(policy); const key = await importPlatformKey("Ed25519 pkcs8", "pkcs8", pkcs8, "Ed25519", policy.extractable, ["sign"]); - return new SigningKey(key, ED25519_ALGORITHM); + return new SigningKey(MINT, key, ED25519_ALGORITHM); }, importSigningKeyJwk: async (jwkText: string, options: SigningKeyOptions): Promise => { const policy = signingPolicyOf(options); @@ -399,7 +585,7 @@ export const ed25519Sign = { ["sign"], ); if (key.type !== "private") errInvalidKey("OKP private JWK must carry `d` (base64url private key)"); - return new SigningKey(key, ED25519_ALGORITHM); + return new SigningKey(MINT, key, ED25519_ALGORITHM); }, unwrapSigningKeyPkcs8: (input: UnwrapInput, options: SigningKeyOptions): Promise => { const { bytes } = consumeUnwrapInput(input); diff --git a/js/polyengine/tests/embedder_keys_test.ts b/js/polyengine/tests/embedder_keys_test.ts new file mode 100644 index 0000000..3601e85 --- /dev/null +++ b/js/polyengine/tests/embedder_keys_test.ts @@ -0,0 +1,342 @@ +// The embedder key seams (polymorph-webcrypto#391): validated injection of +// host-held `CryptoKey`s into the wrapper resources, and extraction back out. +// +// The defensive point of these seams, and so of this file: an embedder that +// must keep a signing key or HKDF input keying material across sessions should +// persist a NON-EXTRACTABLE `CryptoKey` (structured clone into IndexedDB), +// not a pile of exported material. These tests assert the two properties that +// makes safe — extraction preserves non-extractability and clonability, and +// injection REFUSES the degenerate cases (wrong key half, unsupported family, +// a key whose usages do not cover the operation) rather than minting a +// resource that fails later. +// +// No key material is inlined: signing keys are generated in-test, and the one +// fixed input is an obviously-synthetic all-zero 32-byte IKM, labelled as +// such. The suite runs against the platform's own WebCrypto as its oracle +// (sign here / verify there, derive here / derive there), so it needs no +// published vectors. + +import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; +import { hkdfSha2, Ikm, SigningKey, VerifyingKey } from "../src/mod.ts"; +import { arrayStream } from "./testStream.ts"; +import { ComponentException } from "@polyengine/runtime/embedder"; + +function kindOf(err: unknown): string { + return ((err as ComponentException).payload as { kind: string }).kind; +} + +/** Assert a synchronous refusal carries the WIT taxonomy case `kind`. */ +function assertRefuses(kind: string, f: () => unknown, msg: string): void { + const err = assertThrows(f, `${msg}: expected a refusal`); + assertEq(kindOf(err), kind, msg); +} + +const MESSAGE = new TextEncoder().encode("polymorph-webcrypto#391 embedder key seams"); + +/** A non-extractable Ed25519 pair — the posture the persistence seam exists to serve. */ +function ed25519Pair(extractable = false): Promise { + return crypto.subtle.generateKey("Ed25519", extractable, ["sign", "verify"]) as Promise; +} + +Deno.test("391: a non-extractable Ed25519 pair round-trips through injection and still signs/verifies", async () => { + const pair = await ed25519Pair(); + const signing = SigningKey.fromCryptoKey(pair.privateKey); + const verifying = VerifyingKey.fromCryptoKey(pair.publicKey); + + assertEq(signing.algorithmName(), "Ed25519", "injected signing key keeps the Ed25519 mint record"); + assertEq(signing.extractable(), false, "injection preserves non-extractability"); + assertEq(signing.canSign(), true, "the platform sign usage carries across injection"); + + const sig = await signing.sign(arrayStream(MESSAGE)); + assertEq(sig.length, 64, "Ed25519 signature width"); + await verifying.verify(arrayStream(MESSAGE), sig); + + // A tampered signature is a failed verification, not an operational error: + // the WIT pins `authentication-failed` and nothing more detailed. + const tampered = sig.slice(); + tampered[0] ^= 0x01; + const err = await assertRejects(() => verifying.verify(arrayStream(MESSAGE), tampered)); + assertEq(kindOf(err), "authentication-failed", "a tampered signature is refused"); +}); + +Deno.test("391: signing-key injection refuses every degenerate key", async () => { + const pair = await ed25519Pair(); + + assertRefuses( + "invalid-key", + () => SigningKey.fromCryptoKey(pair.publicKey), + "a public key is not a signing key", + ); + + const hkdfKey = await crypto.subtle.importKey( + "raw", + new Uint8Array(32), // synthetic all-zero IKM; never a real secret + "HKDF", + false, + ["deriveBits"], + ); + assertRefuses( + "invalid-key", + () => SigningKey.fromCryptoKey(hkdfKey), + "a secret key is not a signing key", + ); + + // The v1 family boundary: ECDSA's mint-bound digest is not carried by the + // platform key, so injecting one would have to invent it. + const ecdsa = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ) as CryptoKeyPair; + assertRefuses( + "unsupported", + () => SigningKey.fromCryptoKey(ecdsa.privateKey), + "ECDSA injection is outside the v1 boundary", + ); + + assertRefuses( + "invalid-key", + () => SigningKey.fromCryptoKey({ type: "private", algorithm: { name: "Ed25519" }, usages: ["sign"] } as CryptoKey), + "a duck-typed stand-in is not a platform CryptoKey", + ); +}); + +Deno.test("391: the sign-usage requirement is defence in depth the platform also enforces", async () => { + // A private Ed25519 key that cannot sign is not constructible on a + // conforming platform: WebCrypto refuses to MINT one, at generate and at + // import alike (empty usages is a SyntaxError). This test records that fact + // — which is why the host-side `not-permitted` check on injection cannot be + // exercised with a real key here — and pins the platform behaviour the check + // is backstopping, so a platform that ever starts minting such a key is + // caught by this suite rather than silently admitted. + await assertRejects( + () => crypto.subtle.generateKey("Ed25519", false, ["verify"]), + "generateKey must refuse a key pair whose private half would have no usage", + ); + const pair = await ed25519Pair(true); + const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey); + await assertRejects( + () => crypto.subtle.importKey("pkcs8", pkcs8, "Ed25519", false, []), + "importKey must refuse a private key with no usages", + ); + + // The corresponding reachable refusal on the verifying half: `verify` is not + // among a PRIVATE key's usages, so the type gate fires first — assert the + // ordering is the strict one (wrong half is invalid-key, not not-permitted). + assertRefuses( + "invalid-key", + () => VerifyingKey.fromCryptoKey(pair.privateKey), + "a private key is not a verifying key", + ); +}); + +Deno.test("391: verifying-key injection refuses the wrong half and the wrong family", async () => { + const rsa = await crypto.subtle.generateKey( + { name: "RSA-PSS", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + false, + ["sign", "verify"], + ) as CryptoKeyPair; + assertRefuses( + "unsupported", + () => VerifyingKey.fromCryptoKey(rsa.publicKey), + "RSA-PSS injection is outside the v1 boundary (the salt length is not on the key)", + ); + + const ecdh = await crypto.subtle.generateKey( + { name: "ECDH", namedCurve: "P-256" }, + false, + ["deriveBits"], + ) as CryptoKeyPair; + assertRefuses( + "unsupported", + () => VerifyingKey.fromCryptoKey(ecdh.publicKey), + "an ECDH public key is not a verifying key", + ); +}); + +Deno.test("391: ikm injection reads its policy off the platform usages and refuses the rest", async () => { + const bitsOnly = await crypto.subtle.importKey("raw", new Uint8Array(32), "HKDF", false, ["deriveBits"]); + const ikm = Ikm.fromCryptoKey(bitsOnly); + assertEq(ikm.canDeriveBits(), true, "the platform deriveBits slot is the policy"); + assertEq(ikm.canDeriveKey(), false, "a slot the platform did not grant is not granted here"); + + const both = await crypto.subtle.importKey("raw", new Uint8Array(32), "HKDF", false, [ + "deriveBits", + "deriveKey", + ]); + const wide = Ikm.fromCryptoKey(both); + assertEq(wide.canDeriveKey(), true, "the deriveKey slot carries across injection"); + + const aes = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + assertRefuses( + "invalid-key", + () => Ikm.fromCryptoKey(aes as CryptoKey), + "an AES key is not HKDF input keying material", + ); + + const pair = await ed25519Pair(); + assertRefuses( + "invalid-key", + () => Ikm.fromCryptoKey(pair.privateKey), + "a private key is not a secret key", + ); + + // A key with neither derive usage cannot be minted by the platform either + // (empty usages is a SyntaxError), which is the same defence-in-depth + // situation as the signing case above. + await assertRejects( + () => crypto.subtle.importKey("raw", new Uint8Array(32), "HKDF", false, []), + "importKey must refuse HKDF material with no derive usage", + ); +}); + +Deno.test("391: injection launders the key, so shadowed accessors cannot lie about policy", async () => { + const pair = await ed25519Pair(); + + // `CryptoKey`'s slots are immutable but its prototype getters are + // shadowable. If the wrapper mirrored the CALLER's object, this would make + // `canSign()` and `extractable()` say whatever the caller wanted. + Object.defineProperty(pair.privateKey, "usages", { value: [], configurable: true }); + Object.defineProperty(pair.privateKey, "extractable", { value: true, configurable: true }); + assertEq(pair.privateKey.usages.length, 0, "the shadow is in place on the argument"); + + const signing = SigningKey.fromCryptoKey(pair.privateKey); + assertEq(signing.canSign(), true, "canSign answers the platform slot, not the shadow"); + assertEq(signing.extractable(), false, "extractable answers the platform slot, not the shadow"); + + // And the laundered key really is usable: the shadow did not make it past + // validation by accident, it was ignored. + const sig = await signing.sign(arrayStream(MESSAGE)); + assertEq(sig.length, 64, "the laundered key signs"); + + // The same laundering on the way in defeats a family lie: shadowing + // `algorithm` cannot smuggle an ECDSA key past the Ed25519 boundary. + const ecdsa = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ) as CryptoKeyPair; + Object.defineProperty(ecdsa.privateKey, "algorithm", { value: { name: "Ed25519" }, configurable: true }); + assertRefuses( + "unsupported", + () => SigningKey.fromCryptoKey(ecdsa.privateKey), + "a shadowed algorithm name does not cross the family boundary", + ); +}); + +Deno.test("391: extraction hands back a fresh clone and preserves non-extractability", async () => { + const pair = await ed25519Pair(); + const signing = SigningKey.fromCryptoKey(pair.privateKey); + const verifying = VerifyingKey.fromCryptoKey(pair.publicKey); + + const a = signing.toCryptoKey(); + const b = signing.toCryptoKey(); + assertTrue(a !== b, "each extraction is a fresh clone, never the wrapper's own key"); + assertEq(a.extractable, false, "non-extractability survives extraction"); + assertEq(a.type, "private", "the extracted key is the private half"); + assertEq(a.algorithm.name, "Ed25519", "the extracted key keeps its family"); + + // The IndexedDB persistence property, testable without IndexedDB: the + // returned key is structured-clonable, which is the storage path's + // precondition. + const persisted = structuredClone(a); + assertEq(persisted.extractable, false, "a persisted key stays non-extractable"); + assertEq(persisted.usages.includes("sign"), true, "a persisted key keeps its usage"); + + // Nothing a caller does to a returned clone is observable by the wrapper. + Object.defineProperty(a, "usages", { value: [], configurable: true }); + // deno-lint-ignore no-explicit-any + (a as any).expando = "caller scribble"; + assertEq(signing.canSign(), true, "expandos on a returned clone do not reach the wrapper"); + assertEq(signing.extractable(), false, "nor does anything else the caller writes"); + assertTrue(!("expando" in signing.toCryptoKey()), "the next extraction is unpolluted"); + + const vClone = verifying.toCryptoKey(); + assertEq(vClone.type, "public", "the verifying half extracts as the public key"); + assertTrue(vClone !== verifying.toCryptoKey(), "verifying-key extraction is also per-call"); +}); + +Deno.test("391: extraction hands back the same key, not a lookalike", async () => { + const pair = await ed25519Pair(); + const signing = SigningKey.fromCryptoKey(pair.privateKey); + const verifying = VerifyingKey.fromCryptoKey(pair.publicKey); + + // Sign with the EXTRACTED key through the platform, verify through the + // wrapper: agreement proves the extracted handle carries the same material, + // and that a persisted-then-reloaded key is interchangeable with the one + // that was injected. + const platformSig = new Uint8Array( + await crypto.subtle.sign("Ed25519", signing.toCryptoKey(), MESSAGE), + ); + await verifying.verify(arrayStream(MESSAGE), platformSig); + + // And the mirror: sign through the wrapper, verify with the extracted + // public key through the platform. + const wrapperSig = await signing.sign(arrayStream(MESSAGE)); + const ok = await crypto.subtle.verify("Ed25519", verifying.toCryptoKey(), wrapperSig as Uint8Array, MESSAGE); + assertEq(ok, true, "the extracted public key verifies the wrapper's signature"); + + // A full persistence round trip: extract, structured-clone (the IndexedDB + // step), re-inject, and sign again. + const reloaded = SigningKey.fromCryptoKey(structuredClone(signing.toCryptoKey())); + await verifying.verify(arrayStream(MESSAGE), await reloaded.sign(arrayStream(MESSAGE))); +}); + +Deno.test("391: the key constructors are unreachable from outside their minting interfaces", async () => { + const pair = await ed25519Pair(); + + // The construction token is module-private and unexported from mod.ts, so + // no argument a consumer can produce satisfies the guard. + assertRefuses( + "other", + // deno-lint-ignore no-explicit-any + () => new (SigningKey as any)(pair.privateKey, { name: "Ed25519", signatureLength: 64 }), + "a signing key cannot be constructed directly", + ); + assertRefuses( + "other", + // deno-lint-ignore no-explicit-any + () => new (VerifyingKey as any)(Symbol("forged"), pair.publicKey, { name: "Ed25519", signatureLength: 64 }), + "a forged token does not satisfy the verifying-key guard", + ); + + // `Ikm`'s constructor is internal by a different mechanism (all state lives + // in a module-private WeakMap), so a bare instance is inert rather than + // rejected at construction. + assertThrows(() => new Ikm().canDeriveBits(), "a bare Ikm carries no policy"); +}); + +Deno.test("391: an injected ikm derives exactly what the platform derives", async () => { + // Synthetic, labelled inputs: an all-zero 32-byte IKM and short ASCII + // salt/info. The oracle is the platform's own HKDF over the same key. + const ikmBytes = new Uint8Array(32); + const salt = new TextEncoder().encode("391-salt"); + const info = new TextEncoder().encode("391-info"); + + const key = await crypto.subtle.importKey("raw", ikmBytes, "HKDF", false, ["deriveBits"]); + const ikm = Ikm.fromCryptoKey(key); + + const input = await hkdfSha2.prepare("sha256", ikm, salt, info); + const viaWrapper = await input.deriveBits(256); + + const viaPlatform = new Uint8Array( + await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, key, 256), + ); + assertEq(viaWrapper.length, 32, "256 bits derived"); + assertEq( + viaWrapper.every((b, i) => b === viaPlatform[i]), + true, + "the injected ikm derives the platform's answer", + ); + + // Extraction round-trips the material too: re-inject the extracted key and + // derive the same bits. + const reloaded = Ikm.fromCryptoKey(structuredClone(ikm.toCryptoKey())); + assertEq(reloaded.canDeriveBits(), true, "the derive policy survives the round trip"); + const again = await (await hkdfSha2.prepare("sha256", reloaded, salt, info)).deriveBits(256); + assertEq( + again.every((b, i) => b === viaPlatform[i]), + true, + "a persisted-and-reloaded ikm is the same keying material", + ); +});