diff --git a/js/polyengine/src/aead.ts b/js/polyengine/src/aead.ts index f92542e..3ff6b3a 100644 --- a/js/polyengine/src/aead.ts +++ b/js/polyengine/src/aead.ts @@ -16,6 +16,7 @@ import { WrapInput, } from "./wrapping.ts"; import type { Stream } from "@polyengine/runtime/embedder"; +import { MINT, requireMint } from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -90,7 +91,16 @@ export class AeadKey { #lengthBits: number; #grants: AeadPolicy; - constructor(key: CryptoKey, lengthBits: number, grants: AeadPolicy) { + /** + * Runtime-internal (polymorph-webcrypto#391): an `aead-key` exists only as + * minted by the `aes-gcm` interface below. This kind has NO `fromCryptoKey` + * — see the tier statement in signature.ts: `can-seal || can-wrap` collapse + * onto "encrypt" and `can-open || can-unwrap` onto "decrypt" (aead.ts:68-72), + * so a platform key cannot say which grants were actually given and + * injection would silently widen the key's authority on every reload. + */ + constructor(token: typeof MINT, key: CryptoKey, lengthBits: number, grants: AeadPolicy) { + requireMint(token, "aead-key"); this.#key = key; this.#lengthBits = lengthBits; this.#grants = { ...grants }; @@ -232,7 +242,7 @@ async function importAesGcmKey(bits: number, raw: Uint8Array, options: AeadKeyOp const usages = platformUsages(policy); const key = await platformCall("AES-GCM import key", () => subtle.importKey("raw", asBufferSource(raw), { name: "AES-GCM", length: bits }, policy.extractable, usages)); - return new AeadKey(key as CryptoKey, bits, policy); + return new AeadKey(MINT, key as CryptoKey, bits, policy); } /** The `polymorph:webcrypto/aes-gcm@0.1.0` interface. */ @@ -258,7 +268,7 @@ export const aesGcm = { if (gotBits !== bits) { errInvalidKey(`JWK carries a ${gotBits}-bit key; ${variant} requires ${bits}`); } - return new AeadKey(key, bits, policy); + return new AeadKey(MINT, key, bits, policy); }, generateKey: async (variant: string, options: AeadKeyOptions): Promise => { const bits = aesBits(variant); @@ -266,14 +276,14 @@ export const aesGcm = { const usages = platformUsages(policy); const key = await platformCall(`AES-${bits}-GCM key generation`, () => subtle.generateKey({ name: "AES-GCM", length: bits }, policy.extractable, usages)); - return new AeadKey(key as CryptoKey, bits, policy); + return new AeadKey(MINT, key as CryptoKey, bits, policy); }, deriveKey: async (variant: string, input: DeriveInput, options: AeadKeyOptions): Promise => { const bits = aesBits(variant); const policy = optionsOf(options); const usages = platformUsages(policy); const key = await deriveKeyFrom(input, { name: "AES-GCM", length: bits }, policy.extractable, usages); - return new AeadKey(key, bits, policy); + return new AeadKey(MINT, key, bits, policy); }, unwrapKeyRaw: (variant: string, input: UnwrapInput, options: AeadKeyOptions): Promise => { const { bytes } = consumeUnwrapInput(input); diff --git a/js/polyengine/src/cipher.ts b/js/polyengine/src/cipher.ts index a63bfd5..a55e93e 100644 --- a/js/polyengine/src/cipher.ts +++ b/js/polyengine/src/cipher.ts @@ -31,6 +31,7 @@ import { import { type DeriveInput, deriveKeyFrom } from "./derivation.ts"; import { consumeUnwrapInput, consumeWrapInput, UnwrapInput, WrapInput } from "./wrapping.ts"; import type { Stream } from "@polyengine/runtime/embedder"; +import { MINT, requireMint } from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -141,7 +142,16 @@ export class CipherKey { #lengthBits: number; #grants: CipherPolicy; - constructor(key: CryptoKey, name: CipherName, lengthBits: number, grants: CipherPolicy) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `cipher-key` exists only as + * minted by the `aes-cbc`/`aes-ctr` interfaces below. This kind has NO + * `fromCryptoKey` — see the tier statement in signature.ts: `encrypt`/`wrap` + * and `decrypt`/`unwrap` collapse onto one platform usage each + * (cipher.ts:90-91), so the mint is lossy and injection would have to invent + * the missing half of the policy. + */ + constructor(token: typeof MINT, key: CryptoKey, name: CipherName, lengthBits: number, grants: CipherPolicy) { + requireMint(token, "cipher-key"); this.#key = key; this.#name = name; this.#lengthBits = lengthBits; @@ -260,7 +270,7 @@ function cipherMinting(name: CipherName): CipherMinting { errInvalidKey(`${variant} requires ${expected} key bytes, got ${raw.length}`); } const key = await importPlatformKey(`${variant} key`, "raw", raw, { name }, policy.extractable, usages); - return new CipherKey(key, name, expected * 8, policy); + return new CipherKey(MINT, key, name, expected * 8, policy); }, async importKeyJwk(variant: string, jwk: string, options: CipherKeyOptions): Promise { @@ -274,7 +284,7 @@ function cipherMinting(name: CipherName): CipherMinting { if (gotBits !== lengthBits) { errInvalidKey(`JWK carries a ${gotBits}-bit key; ${variant} requires ${lengthBits}`); } - return new CipherKey(key, name, lengthBits, policy); + return new CipherKey(MINT, key, name, lengthBits, policy); }, async generateKey(variant: string, options: CipherKeyOptions): Promise { @@ -283,7 +293,7 @@ function cipherMinting(name: CipherName): CipherMinting { const bits = aesVariantByteLength(variant) * 8; const key = await platformCall(`${variant} key generation`, () => subtle.generateKey({ name, length: bits }, policy.extractable, usages)) as CryptoKey; - return new CipherKey(key, name, bits, policy); + return new CipherKey(MINT, key, name, bits, policy); }, async deriveKey(variant: string, input: DeriveInput, options: CipherKeyOptions): Promise { @@ -291,7 +301,7 @@ function cipherMinting(name: CipherName): CipherMinting { const usages = cipherUsages(policy); const bits = aesVariantByteLength(variant) * 8; const key = await deriveKeyFrom(input, { name, length: bits }, policy.extractable, usages); - return new CipherKey(key, name, bits, policy); + return new CipherKey(MINT, key, name, bits, policy); }, unwrapKeyRaw(variant: string, input: UnwrapInput, options: CipherKeyOptions): Promise { diff --git a/js/polyengine/src/ecdh.ts b/js/polyengine/src/ecdh.ts index 34adf62..5e439c4 100644 --- a/js/polyengine/src/ecdh.ts +++ b/js/polyengine/src/ecdh.ts @@ -27,6 +27,7 @@ import { import { requireEcJwkCurve, requireOnCurveSec1, requireOnCurveSpki } from "./ec.ts"; import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; import { unwrappedJwk } from "./util.ts"; +import { MINT } from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -67,7 +68,7 @@ export const ecdh = { true, [], ); - return new PublicKey(key); + return new PublicKey(MINT, key); }, importPublicKeySpki: async (variant: string, spki: Uint8Array): Promise => { @@ -82,7 +83,7 @@ export const ecdh = { true, [], ); - return new PublicKey(key); + return new PublicKey(MINT, key); }, importPublicKeyJwk: async (variant: string, jwkText: string): Promise => { @@ -96,7 +97,7 @@ export const ecdh = { true, [], ); - return new PublicKey(key); + return new PublicKey(MINT, key); }, importSecretKeyJwk: async (variant: string, jwkText: string, options: AgreementKeyOptions): Promise => { @@ -116,7 +117,7 @@ export const ecdh = { if (key.type !== "private") { errInvalidKey("EC private JWK must carry `d` (base64url private scalar)"); } - return new SecretKey(key, policy); + return new SecretKey(MINT, key, policy); }, importSecretKeyPkcs8: async ( @@ -135,7 +136,7 @@ export const ecdh = { policy.extractable, AGREEMENT_PLATFORM_USAGES, ); - return new SecretKey(key, policy); + return new SecretKey(MINT, key, policy); }, generateKey: async (variant: string, options: AgreementKeyOptions): Promise<[SecretKey, PublicKey]> => { @@ -148,7 +149,7 @@ export const ecdh = { policy.extractable, AGREEMENT_PLATFORM_USAGES, )) as CryptoKeyPair; - return [new SecretKey(pair.privateKey, policy), new PublicKey(pair.publicKey)]; + return [new SecretKey(MINT, pair.privateKey, policy), new PublicKey(MINT, pair.publicKey)]; }, unwrapSecretKeyJwk: (variant: string, input: UnwrapInput, options: AgreementKeyOptions): Promise => { diff --git a/js/polyengine/src/hkdf.ts b/js/polyengine/src/hkdf.ts index 6cbc87e..0d0c027 100644 --- a/js/polyengine/src/hkdf.ts +++ b/js/polyengine/src/hkdf.ts @@ -1,6 +1,13 @@ // `polymorph:webcrypto/hkdf` + `hkdf-sha2` + `hkdf-sha1` — wit/hkdf.wit. -import { errInvalidKey, errNotPermitted, errOther, errUnsupported, notPermitted, platformCall } from "./errors.ts"; +import { errOther, errUnsupported, notPermitted, platformCall } from "./errors.ts"; +import { + injectedKey, + launderCryptoKey, + requireAlgorithmName, + requireKeyType, + requireSomeUsage, +} from "./internal.ts"; import { DeriveInput, type DerivePolicy, @@ -51,36 +58,22 @@ export class Ikm { * 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. + * {@link injectedKey} / `internal.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 clone = injectedKey(what, key); + requireKeyType(what, clone, "secret"); + requireAlgorithmName(what, clone, "HKDF"); 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"); - } + requireSomeUsage(policy.deriveBits || policy.deriveKey, "ikm", "derive-bits nor derive-key"); return mintIkm(clone, policy); } @@ -106,7 +99,7 @@ export class Ikm { toCryptoKey(): CryptoKey { const state = ikmState.get(this); if (state === undefined) errOther("ikm minted by another provider"); - return structuredClone(state.key); + return launderCryptoKey("ikm extraction", state.key); } } diff --git a/js/polyengine/src/internal.ts b/js/polyengine/src/internal.ts index 9dbfba3..7ce8caf 100644 --- a/js/polyengine/src/internal.ts +++ b/js/polyengine/src/internal.ts @@ -1,5 +1,7 @@ -// Module-private construction token for the resource classes whose -// constructors are runtime-internal (polymorph-webcrypto#391). +// Package-private plumbing for the embedder key seams +// (polymorph-webcrypto#391): the construction token every CryptoKey-holding +// resource class is gated on, and the laundering step every `fromCryptoKey` +// runs before it looks at a caller-supplied key. // // 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 @@ -11,5 +13,104 @@ // 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. */ +import { errInvalidKey, errNotPermitted, errOther, errUnsupported } from "./errors.ts"; + +/** + * The witness that a key resource came out of a minting interface in this + * package. + * + * Every class in this package that holds a `CryptoKey` behind a `#private` + * field takes this as its first constructor argument and refuses anything + * else: `signing-key`, `verifying-key`, `mac-key`, `aead-key`, `cipher-key`, + * `kw-key`, `encryption-key`, `decryption-key`, and key-agreement's + * `secret-key` / `public-key`. The classes whose state lives in a + * module-private `WeakMap` instead (`ikm`, `password`) are already + * unconstructible-in-effect — a bare instance carries no state and every + * method refuses it — and keep their WeakMap provenance check. + */ export const MINT: unique symbol = Symbol("polymorph:webcrypto mint"); + +/** + * The refusal a token-gated constructor renders, phrased to match the + * package's WeakMap provenance idiom ("… minted by another provider", + * derivation.ts:23, aead.ts:31). + */ +export function requireMint(token: unknown, resource: string): void { + if (token !== MINT) { + // Deliberately `other`: this is not a bad key or a denied usage, it is a + // resource that never came from a minting interface at all. + errOther(`${resource} constructed outside its minting interfaces`); + } +} + +/** + * Launder an embedder-supplied `CryptoKey` into a clone this package owns — + * the first step of every `fromCryptoKey` (polymorph-webcrypto#391). + * + * `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, and the same trick works on + * `algorithm`, `type` and `extractable`. 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, and it also denies + * the caller a live handle to the key the resource operates with. + * + * The same call is the clone-OUT step of every `toCryptoKey`, for the mirror + * reason: a fresh clone per call keeps the resource's own key unreachable, so + * nothing a caller writes on a returned key is observable by the resource. + */ +export 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`, injecting or extracting 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. */ +export function requirePlatformKey(what: string, key: CryptoKey): void { + if (!(key instanceof CryptoKey)) { + errInvalidKey(`${what} takes a platform CryptoKey`); + } +} + +/** Shape-check and launder in one step — what every `fromCryptoKey` opens with. */ +export function injectedKey(what: string, key: CryptoKey): CryptoKey { + requirePlatformKey(what, key); + return launderCryptoKey(what, key); +} + +/** The `secret`/`private`/`public` half a `fromCryptoKey` requires, refused as `invalid-key` (a wrong half is a wrong key, not a denied usage). */ +export function requireKeyType(what: string, key: CryptoKey, type: KeyType): void { + if (key.type !== type) { + errInvalidKey(`${what} takes a ${type} key, got a ${key.type} key`); + } +} + +/** The algorithm name a `fromCryptoKey` requires (checked on the laundered clone, so a shadowed `algorithm` cannot cross a family boundary). */ +export function requireAlgorithmName(what: string, key: CryptoKey, name: string): void { + if (key.algorithm.name !== name) { + errInvalidKey(`${what} takes a ${name} key, got ${key.algorithm.name}`); + } +} + +/** + * The at-least-one-usage rule for an injected key. + * + * This mirrors the package-wide mint rule that an options resource granting + * nothing cannot mint (derivation.ts:50-52, errors.ts:139-141): a key that + * permits none of the operations its resource exists to perform is a + * degenerate injection, refused loudly at the seam rather than minted into a + * resource that will only fail at first use. + */ +export function requireSomeUsage(granted: boolean, resource: string, operations: string): void { + if (!granted) { + errNotPermitted(`a ${resource} permitting neither ${operations} cannot be injected`); + } +} diff --git a/js/polyengine/src/keyAgreement.ts b/js/polyengine/src/keyAgreement.ts index 5985500..2da6be4 100644 --- a/js/polyengine/src/keyAgreement.ts +++ b/js/polyengine/src/keyAgreement.ts @@ -13,6 +13,7 @@ import { importPlatformKey, importPlatformKeyJwk, jwkMaterial, redactingInvalidK import { type DeriveInput, mintDeriveInput } from "./derivation.ts"; import { consumeUnwrapInput, type UnwrapInput, WrapInput } from "./wrapping.ts"; import { unwrappedJwk } from "./util.ts"; +import { MINT, requireMint } from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -49,7 +50,16 @@ export class AgreementKeyOptions { /** `key-agreement.public-key`: exchangeable, secret-free. */ export class PublicKey { #key: CryptoKey; - constructor(key: CryptoKey) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `public-key` exists only as + * minted by `x25519`/`ecdh`. No `fromCryptoKey` here — see the tier + * statement in signature.ts: agreement keys are minted with CONSTANT + * platform usages regardless of the policy granted (keyAgreement.ts:240), so + * the policy lives entirely in the resource and an injected key would arrive + * carrying none of it. + */ + constructor(token: typeof MINT, key: CryptoKey) { + requireMint(token, "public-key"); this.#key = key; } get cryptoKey(): CryptoKey { @@ -82,7 +92,13 @@ export class PublicKey { export class SecretKey { #key: CryptoKey; #policy: AgreementPolicy; - constructor(key: CryptoKey, policy: AgreementPolicy) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `secret-key` exists only as + * minted by `x25519`/`ecdh`. No `fromCryptoKey` — the agreement policy is + * not carried by the platform key at all (see `PublicKey`'s constructor). + */ + constructor(token: typeof MINT, key: CryptoKey, policy: AgreementPolicy) { + requireMint(token, "secret-key"); this.#key = key; this.#policy = { ...policy }; } @@ -190,17 +206,17 @@ export const x25519 = { importPublicKeyRaw: async (raw: Uint8Array): Promise => { if (raw.length !== 32) errInvalidKey("X25519 public key must be 32 bytes (RFC 7748 u-coordinate)"); const key = await importPlatformKey("X25519 public key", "raw", raw, "X25519", true, []); - return new PublicKey(key); + return new PublicKey(MINT, key); }, importPublicKeySpki: async (spki: Uint8Array): Promise => { const key = await importPlatformKey("X25519 spki", "spki", spki, "X25519", true, []); - return new PublicKey(key); + return new PublicKey(MINT, key); }, importPublicKeyJwk: async (jwkText: string): Promise => { const jwk = jwkMaterial(jwkText); requireStrictBase64url(jwk.x); const key = await importPlatformKeyJwk("X25519 public JWK", jwk, "X25519", true, []); - return new PublicKey(key); + return new PublicKey(MINT, key); }, importSecretKeyJwk: async (jwkText: string, options: AgreementKeyOptions): Promise => { const policy = agreementPolicyOf(options); @@ -218,7 +234,7 @@ export const x25519 = { if (key.type !== "private") { errInvalidKey("OKP private JWK must carry `d` (base64url private key)"); } - return new SecretKey(key, policy); + return new SecretKey(MINT, key, policy); }, importSecretKeyPkcs8: async (pkcs8: Uint8Array, options: AgreementKeyOptions): Promise => { const policy = agreementPolicyOf(options); @@ -231,14 +247,14 @@ export const x25519 = { policy.extractable, AGREEMENT_PLATFORM_USAGES, ); - return new SecretKey(key, policy); + return new SecretKey(MINT, key, policy); }, generateKey: async (options: AgreementKeyOptions): Promise<[SecretKey, PublicKey]> => { const policy = agreementPolicyOf(options); requireAgreementGrant(policy); const pair = await platformCall("X25519 key generation", () => subtle.generateKey("X25519", policy.extractable, AGREEMENT_PLATFORM_USAGES)) as CryptoKeyPair; - return [new SecretKey(pair.privateKey, policy), new PublicKey(pair.publicKey)]; + return [new SecretKey(MINT, pair.privateKey, policy), new PublicKey(MINT, pair.publicKey)]; }, unwrapSecretKeyJwk: (input: UnwrapInput, options: AgreementKeyOptions): Promise => { const { bytes } = consumeUnwrapInput(input); diff --git a/js/polyengine/src/keyWrap.ts b/js/polyengine/src/keyWrap.ts index d4707fc..6a17795 100644 --- a/js/polyengine/src/keyWrap.ts +++ b/js/polyengine/src/keyWrap.ts @@ -10,6 +10,7 @@ import { errAuthenticationFailed, errInvalidKey, errOther, decryptFailure, notPermitted, platformCall } from "./errors.ts"; import { asBufferSource, unwrappedJwk } from "./util.ts"; import { + AES_VARIANT_BYTES, aesVariantByteLength, exportJwkGated, exportRawGated, @@ -22,7 +23,16 @@ import { } from "./platform.ts"; import { type DeriveInput, deriveKeyFrom } from "./derivation.ts"; import { consumeUnwrapInput, consumeWrapInput, UnwrapInput, WrapInput } from "./wrapping.ts"; -import { grantedUsages } from "./errors.ts"; +import { grantedUsages, errUnsupported } from "./errors.ts"; +import { + injectedKey, + launderCryptoKey, + MINT, + requireAlgorithmName, + requireKeyType, + requireMint, + requireSomeUsage, +} from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -77,12 +87,87 @@ export class KwKey { #lengthBits: number; #grants: KwPolicy; - constructor(key: CryptoKey, lengthBits: number, grants: KwPolicy) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `kw-key` exists only as + * minted by the `aes-kw` interface below or by {@link KwKey.fromCryptoKey}. + */ + constructor(token: typeof MINT, key: CryptoKey, lengthBits: number, grants: KwPolicy) { + requireMint(token, "kw-key"); this.#key = key; this.#lengthBits = lengthBits; this.#grants = { ...grants }; } + /** + * Adopt an embedder-held AES-KW `CryptoKey` — the injection half of the + * persistence seam (polymorph-webcrypto#391), so a key-wrapping key can be + * kept across sessions as a NON-EXTRACTABLE `CryptoKey` in IndexedDB rather + * than as material. + * + * `kw-key` is a served ("tier A") kind: AES-KW's only parameter is the key + * length, which rides `AesKeyAlgorithm`, and the WIT's `can-wrap`/ + * `can-unwrap` grants are 1:1 with the platform's `wrapKey`/`unwrapKey` + * usages (keyWrap.ts:59-65). So the slots determine both the record and the + * policy, and no options parameter is needed — for an injected key the + * platform slots ARE the policy. + * + * Synchronous, and validating: a platform `CryptoKey`, of type `secret`, + * with `algorithm.name === "AES-KW"`, of a length the `aes-kw` mint serves + * — 128 or 256 bits, the `aes-variant` table at platform.ts:28-31 (aes192 is + * declined package-wide by the WIT's portability ruling, so an injected + * 192-bit key is refused exactly as an imported one would be). At least one + * of wrap/unwrap is required: a wrapping key that can do neither is a + * degenerate injection. + * + * NOTE on `extractable()`: this class mirrors extractability from its GRANTS + * record, not from the key (keyWrap.ts:151-153). For an injected key the + * grant is taken from the platform's own `extractable` slot, which is the + * truth the export paths are gated on anyway. + */ + static fromCryptoKey(key: CryptoKey): KwKey { + const what = "kw-key injection"; + const clone = injectedKey(what, key); + requireKeyType(what, clone, "secret"); + requireAlgorithmName(what, clone, "AES-KW"); + const { length } = clone.algorithm as AesKeyAlgorithm; + // The served `aes-variant` lengths, read off the mint's own table rather + // than restated (platform.ts:28-31, via `aesVariantByteLength`). + const served = Object.values(AES_VARIANT_BYTES).some((bytes) => bytes !== undefined && bytes * 8 === length); + if (!served) { + errUnsupported( + `${what}: aes-kw serves 128- and 256-bit keys; this key is ${length} bits`, + ); + } + requireSomeUsage( + clone.usages.includes("wrapKey") || clone.usages.includes("unwrapKey"), + "kw-key", + "wrap nor unwrap", + ); + return new KwKey(MINT, clone, length, { + wrap: clone.usages.includes("wrapKey"), + unwrap: clone.usages.includes("unwrapKey"), + extractable: clone.extractable, + }); + } + + /** + * 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. + * + * Security framing, as on `signing-key`: confidentiality of the material is + * the `extractable` bit's job and stays platform-enforced both ways — this + * hands back a key, not bytes. What the wrapper scopes is the USE capability + * in durable, parameter-free form: a raw AES-KW `CryptoKey` wraps and + * unwraps at its holder's discretion, whereas a `kw-key` carries the grants + * it was minted with. A fresh clone per call keeps `#key` unreachable. + * + * Inverse of {@link KwKey.fromCryptoKey}. + */ + toCryptoKey(): CryptoKey { + return launderCryptoKey("kw-key extraction", this.#key); + } + /** * Encrypt serialized key material (RFC 3394). JWK-format material is * first padded with ASCII spaces to a multiple of 8 bytes — the `aes-kw` @@ -202,7 +287,7 @@ export const aesKw: AesKw = { policy.extractable, usages, ); - return new KwKey(key, expected * 8, policy); + return new KwKey(MINT, key, expected * 8, policy); }, async importKeyJwk(variant: string, jwk: string, options: KwKeyOptions): Promise { @@ -222,7 +307,7 @@ export const aesKw: AesKw = { if (gotBits !== lengthBits) { errInvalidKey(`JWK carries a ${gotBits}-bit key; ${variant} requires ${lengthBits}`); } - return new KwKey(key, lengthBits, policy); + return new KwKey(MINT, key, lengthBits, policy); }, async generateKey(variant: string, options: KwKeyOptions): Promise { @@ -231,7 +316,7 @@ export const aesKw: AesKw = { const bits = aesVariantByteLength(variant) * 8; const key = await platformCall(`${variant} key generation`, () => subtle.generateKey({ name: "AES-KW", length: bits }, policy.extractable, usages)) as CryptoKey; - return new KwKey(key, bits, policy); + return new KwKey(MINT, key, bits, policy); }, async deriveKey(variant: string, input: DeriveInput, options: KwKeyOptions): Promise { @@ -239,7 +324,7 @@ export const aesKw: AesKw = { const usages = kwUsages(policy); const bits = aesVariantByteLength(variant) * 8; const key = await deriveKeyFrom(input, { name: "AES-KW", length: bits }, policy.extractable, usages); - return new KwKey(key, bits, policy); + return new KwKey(MINT, key, bits, policy); }, unwrapKeyRaw(variant: string, input: UnwrapInput, options: KwKeyOptions): Promise { diff --git a/js/polyengine/src/mac.ts b/js/polyengine/src/mac.ts index 4933ab4..01be37b 100644 --- a/js/polyengine/src/mac.ts +++ b/js/polyengine/src/mac.ts @@ -18,6 +18,15 @@ import { deriveKeyFrom, type DeriveInput } from "./derivation.ts"; import { consumeUnwrapInput, type UnwrapInput, WrapInput } from "./wrapping.ts"; import type { Stream } from "@polyengine/runtime/embedder"; import { collectByteStream } from "./util.ts"; +import { + injectedKey, + launderCryptoKey, + MINT, + requireAlgorithmName, + requireKeyType, + requireMint, + requireSomeUsage, +} from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -74,12 +83,93 @@ export class MacKey { #lengthBits: number; #hashName: string; - constructor(key: CryptoKey, lengthBits: number, hashName: string) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `mac-key` exists only as + * minted by one of the HMAC interfaces in this module or by + * {@link MacKey.fromCryptoKey}. `MINT` is package-private and unexported + * from mod.ts, so no external caller can reach this signature. + */ + constructor(token: typeof MINT, key: CryptoKey, lengthBits: number, hashName: string) { + requireMint(token, "mac-key"); this.#key = key; this.#lengthBits = lengthBits; this.#hashName = hashName; } + /** + * Adopt an embedder-held HMAC `CryptoKey` — the injection half of the + * persistence seam (polymorph-webcrypto#391): a MAC key an embedder + * structured-cloned into IndexedDB comes back as a `mac-key` here, so + * keeping one across sessions does not require holding its material. + * + * `mac-key` is a served ("tier A") kind because the platform key's slots + * determine everything the resource needs: `HmacKeyAlgorithm` carries the + * mint-bound hash and length, and the WIT's `can-sign`/`can-verify` grants + * are 1:1 with the platform's `sign`/`verify` usages (mac.ts:56-64), so the + * usages ARE the policy for an injected key — loading is itself a minting + * path, and the platform will refuse anything its slots do not cover + * regardless of what this wrapper claimed. + * + * Synchronous, and validating: a platform `CryptoKey`, of type `secret`, + * with `algorithm.name === "HMAC"`, bound to one of the digests the minting + * interfaces serve — SHA-1 (`hmac-sha1`, mac.ts:233) and SHA-256/384/512 + * (`hmac-sha2`, mac.ts:235-239) — and of a length the mint admits (non-zero + * and a whole number of bytes; mac.ts:170-173). At least one of + * `sign`/`verify` is required: a MAC key that can do neither is a degenerate + * injection. + * + * Validation reads the LAUNDERED clone, which is also what the wrapper + * stores, so shadowed accessors on the argument cannot get a key admitted + * and no caller retains a handle to the key this resource MACs with. + */ + static fromCryptoKey(key: CryptoKey): MacKey { + const what = "mac-key injection"; + const clone = injectedKey(what, key); + requireKeyType(what, clone, "secret"); + requireAlgorithmName(what, clone, "HMAC"); + const { hash, length } = clone.algorithm as HmacKeyAlgorithm; + if (servedHmacSpec(hash.name) === undefined) { + errUnsupported( + `${what}: the HMAC interfaces serve SHA-1 and SHA-256/384/512; this key is bound to ${hash.name}`, + ); + } + // The mint's own length rules, applied to the slot instead of to raw + // material (mac.ts:157, 170-173): an empty key cannot be imported and a + // sub-byte length is not served. + if (length === 0) errInvalidKey(`${what}: empty key`); + if (length % 8 !== 0) { + errUnsupported(`HMAC key length ${length} is not a multiple of 8; sub-byte lengths are not served`); + } + requireSomeUsage( + clone.usages.includes("sign") || clone.usages.includes("verify"), + "mac-key", + "sign nor verify", + ); + return new MacKey(MINT, clone, length, hash.name); + } + + /** + * 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, which is how a MAC key is + * meant to outlive a session. + * + * Security framing, as on `signing-key`: material confidentiality is the + * `extractable` bit's job and stays platform-enforced both ways — this hands + * back a key, never bytes, and a non-extractable key stays non-extractable + * in the caller's hands. What the wrapper scopes is the USE capability in + * durable, parameter-free form: a raw HMAC `CryptoKey` MACs whatever its + * holder asks, whereas a `mac-key` is bound to the hash and grants it was + * minted with. A fresh clone per call keeps `#key` unreachable, so that + * scoping is total. + * + * Inverse of {@link MacKey.fromCryptoKey}: the returned key satisfies that + * validation by construction. + */ + toCryptoKey(): CryptoKey { + return launderCryptoKey("mac-key extraction", this.#key); + } + extractable(): boolean { return this.#key.extractable; } @@ -157,7 +247,7 @@ async function importHmacKey(resolved: HashSpec, raw: Uint8Array, options: MacKe if (raw.length === 0) errInvalidKey("empty key"); const key = await platformCall("HMAC import key", () => subtle.importKey("raw", asBufferSource(raw), { name: "HMAC", hash: resolved.hash }, policy.extractable, usages)); - return new MacKey(key, raw.length * 8, resolved.hash); + return new MacKey(MINT, key, raw.length * 8, resolved.hash); } async function generateHmacKey( @@ -174,7 +264,7 @@ async function generateHmacKey( const bits = length ?? resolved.blockBytes * 8; const key = await platformCall(`HMAC-${resolved.hash} key generation`, () => subtle.generateKey({ name: "HMAC", hash: resolved.hash, length: bits }, policy.extractable, usages)); - return new MacKey(key as CryptoKey, bits, resolved.hash); + return new MacKey(MINT, key as CryptoKey, bits, resolved.hash); } /** @@ -198,7 +288,7 @@ async function importHmacKeyJwk(resolved: HashSpec, jwk: string, options: MacKey usages, ); const kLen = typeof material.k === "string" ? jwkKeyBytes(material.k) * 8 : 0; - return new MacKey(key, kLen, resolved.hash); + return new MacKey(MINT, key, kLen, resolved.hash); } async function deriveHmacKey( @@ -215,7 +305,7 @@ async function deriveHmacKey( } const bits = length ?? resolved.blockBytes * 8; const key = await deriveKeyFrom(input, { name: "HMAC", hash: resolved.hash, length: bits }, policy.extractable, usages); - return new MacKey(key, bits, resolved.hash); + return new MacKey(MINT, key, bits, resolved.hash); } function unwrapHmacKeyRaw(resolved: HashSpec, input: UnwrapInput, options: MacKeyOptions): Promise { @@ -244,6 +334,17 @@ function sha2Hmac(variant: string): HashSpec { return spec; } +/** + * The `HashSpec` for a WebCrypto digest NAME, or `undefined` if the HMAC + * interfaces do not serve it — the served set read off the mint tables + * themselves (`SHA1_HMAC` and `SHA2_HMAC` above) rather than restated, so + * `mac-key` injection admits exactly the digests a mint does. + */ +function servedHmacSpec(hashName: string): HashSpec | undefined { + if (hashName === SHA1_HMAC.hash) return SHA1_HMAC; + return Object.values(SHA2_HMAC).find((spec) => spec !== undefined && spec.hash === hashName); +} + /** The `polymorph:webcrypto/hmac-sha1@0.1.0` interface. */ export const hmacSha1 = { importKeyRaw: (raw: Uint8Array, options: MacKeyOptions): Promise => importHmacKey(SHA1_HMAC, raw, options), diff --git a/js/polyengine/src/pbkdf2.ts b/js/polyengine/src/pbkdf2.ts index 98808be..4eb97c3 100644 --- a/js/polyengine/src/pbkdf2.ts +++ b/js/polyengine/src/pbkdf2.ts @@ -18,6 +18,13 @@ import { import { asBufferSource } from "./util.ts"; import { redactingInvalidKey, served, SHA1_ENTRY, SHA2_VARIANTS } from "./platform.ts"; import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; +import { + injectedKey, + launderCryptoKey, + requireAlgorithmName, + requireKeyType, + requireSomeUsage, +} from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -33,6 +40,11 @@ function passwordOf(p: Password): { key: CryptoKey; policy: DerivePolicy } { * `pbkdf2.password`: a password as a `PBKDF2`-bound platform key. The * platform forces non-extractability at import, and the WIT grants ride * the key's usages (reference: webcrypto.js:1570). + * + * The constructor is internal by construction — all state lives in the + * module-private `passwordState` WeakMap, so a bare `new Password()` yields an + * object every method refuses. The supported external construction path is + * {@link Password.fromCryptoKey} (polymorph-webcrypto#391). */ export class Password { canDeriveBits(): boolean { @@ -41,6 +53,74 @@ export class Password { canDeriveKey(): boolean { return passwordOf(this).policy.deriveKey; } + + /** + * Adopt an embedder-held PBKDF2 `CryptoKey` — the injection half of the + * persistence seam (polymorph-webcrypto#391): an embedder that keeps a + * password-derived key handle as a NON-EXTRACTABLE `CryptoKey` in IndexedDB + * gets it back as a `password` here, instead of having to retain the + * password bytes. + * + * `password` is a served ("tier A") kind: PBKDF2 keys take no parameters at + * mint — salt, iteration count and digest are all bound later, at `prepare` + * — and the WIT's `can-derive-bits`/`can-derive-key` grants are 1:1 with the + * platform's `deriveBits`/`deriveKey` usages (pbkdf2.ts:46-54, via + * derivation.ts:45-54). So the policy is READ OFF THE PLATFORM USAGES rather + * than taken from a `derive-options`: for an injected key the slots ARE the + * policy, loading being itself a minting path, and the platform refuses + * anything the slots do not cover regardless of what this wrapper claimed. + * + * Synchronous, and validating: a platform `CryptoKey`, of type `secret`, + * with `algorithm.name === "PBKDF2"`, permitting at least one derive + * operation — a password permitting neither is a degenerate injection. + * + * Validation and storage both use a LAUNDERED clone: `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 + * `password` derives with. + */ + static fromCryptoKey(key: CryptoKey): Password { + const what = "password injection"; + const clone = injectedKey(what, key); + requireKeyType(what, clone, "secret"); + requireAlgorithmName(what, clone, "PBKDF2"); + const policy: DerivePolicy = { + deriveBits: clone.usages.includes("deriveBits"), + deriveKey: clone.usages.includes("deriveKey"), + }; + requireSomeUsage(policy.deriveBits || policy.deriveKey, "password", "derive-bits nor derive-key"); + return mintPassword(clone, policy); + } + + /** + * 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 (PBKDF2 keys are minted + * non-extractable by the platform in any case, pbkdf2.ts:50). + * + * Security framing, as on `signing-key`: confidentiality of the material is + * the `extractable` bit's job and stays platform-enforced — this hands back + * a key, never the password bytes. What the wrapper scopes is the USE + * capability in durable, parameter-free form: a raw PBKDF2 `CryptoKey` + * derives under any salt, iteration count and digest its holder picks, + * whereas a `password` is consumable only through `prepare` under the policy + * above and under an iteration count `prepare` validates. A fresh clone per + * call keeps the wrapper's own key unreachable. + * + * Inverse of {@link Password.fromCryptoKey}: the derive policy round-trips + * through the platform usages. + */ + toCryptoKey(): CryptoKey { + return launderCryptoKey("password extraction", passwordOf(this).key); + } +} + +/** Bind a platform key and its policy to a fresh `password` — the one construction path for the WeakMap state. */ +function mintPassword(key: CryptoKey, policy: DerivePolicy): Password { + const password = new Password(); + passwordState.set(password, { key, policy: { ...policy } }); + return password; } async function importPassword(raw: Uint8Array, options: DeriveOptions): Promise { @@ -48,9 +128,7 @@ async function importPassword(raw: Uint8Array, options: DeriveOptions): Promise< const usages = deriveUsages(policy); const key = await platformCall("PBKDF2 password import", () => subtle.importKey("raw", asBufferSource(raw), "PBKDF2", false, usages)); - const password = new Password(); - passwordState.set(password, { key, policy }); - return password; + return mintPassword(key, policy); } /** The `polymorph:webcrypto/pbkdf2@0.1.0` interface. */ diff --git a/js/polyengine/src/publicEncryption.ts b/js/polyengine/src/publicEncryption.ts index 9bf3ee0..f370634 100644 --- a/js/polyengine/src/publicEncryption.ts +++ b/js/polyengine/src/publicEncryption.ts @@ -40,6 +40,7 @@ import { rsaJwkAlgPrefix, } from "./rsaSignature.ts"; import { consumeUnwrapInput, consumeWrapInput, UnwrapInput, WrapInput } from "./wrapping.ts"; +import { MINT, requireMint } from "./internal.ts"; const subtle = globalThis.crypto.subtle; @@ -104,7 +105,15 @@ export class EncryptionKey { #key: CryptoKey; #algorithm: OaepAlgorithm; - constructor(key: CryptoKey, algorithm: OaepAlgorithm) { + /** + * Runtime-internal (polymorph-webcrypto#391): an `encryption-key` exists + * only as minted by the `rsa-oaep-*` interfaces below. No `fromCryptoKey`: + * the public half is excluded with its private counterpart for family + * coherence (see the tier statement in signature.ts) — a seam on the public + * half alone has no consumer. + */ + constructor(token: typeof MINT, key: CryptoKey, algorithm: OaepAlgorithm) { + requireMint(token, "encryption-key"); this.#key = key; this.#algorithm = algorithm; } @@ -204,7 +213,16 @@ export class DecryptionKey { #algorithm: OaepAlgorithm; #grants: DecryptionPolicy; - constructor(key: CryptoKey, algorithm: OaepAlgorithm, grants: DecryptionPolicy) { + /** + * Runtime-internal (polymorph-webcrypto#391): a `decryption-key` exists only + * as minted by the `rsa-oaep-decrypt` interface below. This kind has NO + * `fromCryptoKey` — see the tier statement in signature.ts: + * `can-decrypt || can-unwrap` both become "decrypt" + * (publicEncryption.ts:188-191), so the platform key cannot distinguish the + * two grants. + */ + constructor(token: typeof MINT, key: CryptoKey, algorithm: OaepAlgorithm, grants: DecryptionPolicy) { + requireMint(token, "decryption-key"); this.#key = key; this.#algorithm = algorithm; this.#grants = { ...grants }; @@ -288,7 +306,7 @@ export const rsaOaepEncrypt = { ["encrypt"], ); const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP spki"); - return new EncryptionKey(key, oaepAlgorithm(entry, modulusLength)); + return new EncryptionKey(MINT, key, oaepAlgorithm(entry, modulusLength)); }, importEncryptionKeyJwk: async (variant: string, jwkText: string): Promise => { const entry = served(RSA_VARIANTS, variant); @@ -304,7 +322,7 @@ export const rsaOaepEncrypt = { ["encrypt"], ); const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP public JWK"); - return new EncryptionKey(key, oaepAlgorithm(entry, modulusLength)); + return new EncryptionKey(MINT, key, oaepAlgorithm(entry, modulusLength)); }, }; @@ -328,8 +346,8 @@ export const rsaOaepDecrypt = { )) as CryptoKeyPair; const algorithm = oaepAlgorithm(entry, modulusLength); return [ - new DecryptionKey(pair.privateKey, algorithm, policy), - new EncryptionKey(pair.publicKey, algorithm), + new DecryptionKey(MINT, pair.privateKey, algorithm, policy), + new EncryptionKey(MINT, pair.publicKey, algorithm), ]; }, @@ -351,7 +369,7 @@ export const rsaOaepDecrypt = { usages, ); const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP pkcs8"); - return new DecryptionKey(key, oaepAlgorithm(entry, modulusLength), policy); + return new DecryptionKey(MINT, key, oaepAlgorithm(entry, modulusLength), policy); }, importDecryptionKeyJwk: async ( @@ -377,7 +395,7 @@ export const rsaOaepDecrypt = { ); if (key.type !== "private") errInvalidKey("RSA private JWK must carry `d` and the CRT members"); const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP private JWK"); - return new DecryptionKey(key, oaepAlgorithm(entry, modulusLength), policy); + return new DecryptionKey(MINT, key, oaepAlgorithm(entry, modulusLength), policy); }, unwrapDecryptionKeyPkcs8: ( diff --git a/js/polyengine/src/rsaSignature.ts b/js/polyengine/src/rsaSignature.ts index 3016106..b6371a1 100644 --- a/js/polyengine/src/rsaSignature.ts +++ b/js/polyengine/src/rsaSignature.ts @@ -312,3 +312,51 @@ export const rsassaPkcs1V15Sign: RsaSigningInterface = rsaSigningInterface("RSAS /** The `polymorph:webcrypto/rsa-pss-sign@0.1.0` interface. */ export const rsaPssSign: RsaSigningInterface = rsaSigningInterface("RSA-PSS"); + +/** + * The mint-bound record for an INJECTED RSASSA-PKCS1-v1_5 key + * (polymorph-webcrypto#391), rebuilt from the platform key's own + * `RsaHashedKeyAlgorithm` slots. + * + * RSASSA is the one RSA family whose record the slots fully determine: the + * digest, modulus length and public exponent all ride the key, and the scheme + * takes no per-mint parameter (contrast RSA-PSS, whose salt length is a mint + * choice the key does not carry — which is why it stays excluded). + * + * Every admission rule is the MINT PATH'S OWN, called here rather than + * restated: + * - the served digest set is `RSA_VARIANTS` (= `SHA2_VARIANTS`, this file's + * line 38-39): SHA-256/384/512, with SHA-1 deliberately absent from the + * RSA families. A key bound to any other digest is refused. + * - the modulus window and the odd-and-at-least-3 public exponent rule come + * from `rsaAdmittedModulusLength` (this file, line 57), with the same + * windows the import paths use — the private half on the tightened + * signing window (`RSA_SIGNING_MIN_BITS`..`RSA_SIGNING_MAX_BITS`), the + * public half on the wider verification window. + * - the private half additionally runs `requireRsaPrivateKeysServed`, so an + * embedding that declined RSA private-key operations cannot have the + * decline bypassed by injecting a platform key. + * + * The record itself is built by `rsaSigningAlgorithm`, the same builder the + * import and generate paths use, so an injected key is indistinguishable from + * an imported one downstream. + */ +export function rsassaInjectedAlgorithm( + what: string, + key: CryptoKey, + half: "private" | "public", +): SignatureAlgorithm { + const name = "RSASSA-PKCS1-v1_5"; + if (half === "private") requireRsaPrivateKeysServed(); + const { hash } = key.algorithm as RsaHashedKeyAlgorithm; + const entry = Object.values(RSA_VARIANTS).find((v) => v !== undefined && v.hash === hash.name); + if (entry === undefined) { + errUnsupported( + `${what}: ${name} is served over SHA-256/SHA-384/SHA-512; this key is bound to ${hash.name}`, + ); + } + const modulusLength = half === "private" + ? rsaAdmittedModulusLength(key, `${name} injection`, RSA_SIGNING_MIN_BITS, RSA_SIGNING_MAX_BITS) + : rsaAdmittedModulusLength(key, `${name} injection`); + return rsaSigningAlgorithm(name, entry, modulusLength); +} diff --git a/js/polyengine/src/signature.ts b/js/polyengine/src/signature.ts index 0aaa1e4..e5dd557 100644 --- a/js/polyengine/src/signature.ts +++ b/js/polyengine/src/signature.ts @@ -32,7 +32,17 @@ 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 { injectedKey, launderCryptoKey, MINT, requireKeyType, requireMint } from "./internal.ts"; +// The RSASSA injection admission lives with the family's own validators and +// record builder (rsaSignature.ts), which is where the mint paths keep them — +// importing it here rather than restating the rules is the point. +// +// This is an import CYCLE (rsaSignature.ts imports this module's resource +// classes). It is safe by evaluation order rather than by luck: neither +// module touches the other's bindings at module-evaluation time — every use +// is inside a function body — so whichever module is entered first completes +// the other's evaluation before any call can occur. +import { rsassaInjectedAlgorithm } from "./rsaSignature.ts"; import type { Stream } from "@polyengine/runtime/embedder"; const subtle = globalThis.crypto.subtle; @@ -150,57 +160,91 @@ function ed25519PointStrict(encoded: Uint8Array): boolean { // 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. +// +// --- WHICH KINDS HAVE SEAMS, AND WHY THE REST DO NOT. +// +// The admission rule is a single question: do the platform key's slots FULLY +// DETERMINE both the mint-bound record and the WIT policy? Where they do, a +// `CryptoKey` is a lossless carrier of the resource and `fromCryptoKey` is +// exactly its inverse. Where they do not, injection would have to INVENT the +// missing half — and inventing policy or bindings on a security resource is +// the failure mode these seams exist to avoid, so those kinds are refused +// outright rather than served approximately. +// +// SERVED (slots determine everything): +// - `signing-key` / `verifying-key` for Ed25519 and RSASSA-PKCS1-v1_5. +// Ed25519's record is a constant; RSASSA's hash, modulus length and +// public exponent all ride `RsaHashedKeyAlgorithm`. Policy is `sign` / +// `verify`, 1:1 with the platform usages. +// - `mac-key`: the HMAC hash and length ride `HmacKeyAlgorithm`; the +// `can-sign`/`can-verify` grants are 1:1 with the platform usages +// (mac.ts:56-64). +// - `kw-key`: AES-KW's length is the only parameter; `can-wrap`/ +// `can-unwrap` are 1:1 with `wrapKey`/`unwrapKey` (keyWrap.ts:59-65). +// - `ikm` (hkdf.ts) and `password` (pbkdf2.ts): `can-derive-bits`/ +// `can-derive-key` are 1:1 with `deriveBits`/`deriveKey` +// (derivation.ts:45-54, pbkdf2.ts:46-54). +// +// EXCLUDED — POLICY COLLAPSE. These kinds carry more WIT grants than the +// platform has usages to hold them in, so the mint is lossy: the platform key +// cannot say which of the collapsed grants were actually given, and reading +// the wider grant off the narrower usage would SILENTLY WIDEN the key's +// authority on every reload. +// - `aead-key`: `can-seal || can-wrap` both become "encrypt", and +// `can-open || can-unwrap` both become "decrypt" (aead.ts:68-72). +// - `cipher-key`: same collapse, `encrypt`/`wrap` and `decrypt`/`unwrap` +// (cipher.ts:90-91). +// - `decryption-key`: `can-decrypt || can-unwrap` both become "decrypt" +// (publicEncryption.ts:188-191). `encryption-key` is excluded with it: +// the public half alone has no consumer, and splitting a family's seams +// across its two halves is an API seam nobody asked for. +// +// EXCLUDED — POLICY IS NOT ON THE KEY AT ALL. Key-agreement's `secret-key` +// and `public-key` are minted with CONSTANT platform usages regardless of the +// policy the caller granted (keyAgreement.ts:240); the whole policy lives in +// the resource's own state, so an injected key would arrive with no policy +// whatsoever. +// +// EXCLUDED — MINT BINDINGS ABSENT FROM THE KEY. ECDSA's per-mint digest and +// RSA-PSS's salt length are chosen at mint and are NOT carried by the +// platform key (see `SignatureAlgorithm` above, and the file header on why +// `CryptoKey.algorithm` is never the authority). Injecting one would have to +// invent the binding or read it off the untrusted key; both are refused. +// +// Admitting any excluded kind needs an explicit policy-or-bindings parameter +// alongside the key. The anticipated shape is the family's existing mint +// options resource (`aead-key-options`, `cipher-key-options`, +// `agreement-key-options`, …), which already spells exactly the grants that +// collapse — but that is an API addition, and its design waits for a +// consumer with a concrete persistence requirement. No stub methods in the +// meantime: an excluded class simply has no `fromCryptoKey`, so the refusal +// is a type error rather than a runtime surprise. /** - * 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. + * The signature families key injection serves, and the record each one's + * platform slots determine. * - * 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. + * Ed25519's record is the module constant. RSASSA-PKCS1-v1_5's is rebuilt + * from the key's own `RsaHashedKeyAlgorithm` slots THROUGH THE MINT PATH'S + * OWN validators and record builder (rsaSignature.ts), so an injected key is + * admitted on exactly the terms an imported one is and carries an identical + * record. ECDSA and RSA-PSS keep the named refusal. */ -function requireEd25519Injection(what: string, algorithmName: string): void { - if (algorithmName !== "Ed25519") { +function injectedSignatureAlgorithm(what: string, key: CryptoKey, half: "private" | "public"): SignatureAlgorithm { + const name = key.algorithm.name; + if (name === "Ed25519") return ED25519_ALGORITHM; + if (name === "RSASSA-PKCS1-v1_5") return rsassaInjectedAlgorithm(what, key, half); + if (name === "ECDSA" || name === "RSA-PSS") { errUnsupported( - `${what} serves Ed25519 only: a ${algorithmName} key's mint bindings ` + + `${what} does not serve ${name}: its 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", ); } + errUnsupported( + `${what} does not serve ${name}: the served signature families for key injection are ` + + "Ed25519 and RSASSA-PKCS1-v1_5", + ); } /** `signature.verifying-key`: a public key, secret-free. */ @@ -217,7 +261,7 @@ export class VerifyingKey { * reach this signature. */ constructor(token: typeof MINT, key: CryptoKey, algorithm: SignatureAlgorithm) { - if (token !== MINT) errOther("verifying-key constructed outside its minting interfaces"); + requireMint(token, "verifying-key"); this.#key = key; this.#algorithm = algorithm; } @@ -228,23 +272,25 @@ export class VerifyingKey { * 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 + * type `public`, of a served family (Ed25519 or RSASSA-PKCS1-v1_5 — see the + * tier statement above), 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. + * + * RSASSA keys are admitted on the VERIFYING window (1024-16384 bits), the + * same window `rsassa-pkcs1-v15-verify`'s import paths use — verification + * is a public operation over an attacker-supplied key, so it is deliberately + * wider than the signing window. */ 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); + const clone = injectedKey(what, key); + requireKeyType(what, clone, "public"); + const algorithm = injectedSignatureAlgorithm(what, clone, "public"); if (!clone.usages.includes("verify")) notPermitted("verify"); - return new VerifyingKey(MINT, clone, ED25519_ALGORITHM); + return new VerifyingKey(MINT, clone, algorithm); } /** @@ -344,7 +390,7 @@ export class SigningKey { * 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"); + requireMint(token, "signing-key"); this.#key = key; this.#algorithm = algorithm; } @@ -356,26 +402,40 @@ export class SigningKey { * 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. + * a served family (Ed25519 or RSASSA-PKCS1-v1_5 — see the tier statement + * above), 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. + * RSASSA keys are admitted on the SIGNING window (2048-8192 bits, + * rsaSignature.ts:45-46) with the same odd-and-at-least-3 exponent rule the + * import paths apply, and are subject to the same `setRsaPrivateKeyPolicy` + * decline — a posture that gates RSA private-key operations must gate the + * injection path too, or it would be bypassable by an embedder holding a + * platform key. + * + * HONEST ASYMMETRY: the import paths run MATERIAL-based checks that have no + * slot analogue and therefore cannot run here — Ed25519's point strictness + * (canonical, non-small-order `A`) is verified over the encoded key at + * import, and the JWK paths check strict base64url and the JOSE `alg` + * spelling. An injected key was minted by the platform from material this + * package never saw, so those checks are neither possible nor meaningful; + * what IS checked is everything the slots carry, on the same terms as an + * import. + * + * Validation reads the LAUNDERED clone, 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); + const clone = injectedKey(what, key); + requireKeyType(what, clone, "private"); + const algorithm = injectedSignatureAlgorithm(what, clone, "private"); if (!clone.usages.includes("sign")) notPermitted("sign"); - return new SigningKey(MINT, clone, ED25519_ALGORITHM); + return new SigningKey(MINT, clone, algorithm); } /** diff --git a/js/polyengine/tests/embedder_keys_test.ts b/js/polyengine/tests/embedder_keys_test.ts index 3601e85..4c701a5 100644 --- a/js/polyengine/tests/embedder_keys_test.ts +++ b/js/polyengine/tests/embedder_keys_test.ts @@ -17,7 +17,24 @@ // published vectors. import { assertEq, assertRejects, assertThrows, assertTrue } from "./asserts.ts"; -import { hkdfSha2, Ikm, SigningKey, VerifyingKey } from "../src/mod.ts"; +import { + AeadKey, + CipherKey, + DecryptionKey, + EncryptionKey, + hkdfSha2, + hmacSha2, + Ikm, + KwKey, + MacKey, + MacKeyOptions, + Password, + pbkdf2Sha2, + PublicKey, + SecretKey, + SigningKey, + VerifyingKey, +} from "../src/mod.ts"; import { arrayStream } from "./testStream.ts"; import { ComponentException } from "@polyengine/runtime/embedder"; @@ -31,6 +48,61 @@ function assertRefuses(kind: string, f: () => unknown, msg: string): void { assertEq(kindOf(err), kind, msg); } +/** A mint options resource granting both MAC directions. */ +function macOptions(): MacKeyOptions { + const o = new MacKeyOptions(); + o.canSign(true); + o.canVerify(true); + return o; +} + +/** An RSASSA-PKCS1-v1_5 pair at a given modulus and digest, generated in-test. */ +async function rsassaPair(modulusLength: number, hash: string): Promise { + return await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength, + // F4 (65537), the platform's own default exponent — public parameter, + // not key material. + publicExponent: new Uint8Array([1, 0, 1]), + hash, + }, + false, + ["sign", "verify"], + ) as CryptoKeyPair; +} + +/** + * The kinds excluded from the seams because their WIT grants collapse onto + * fewer platform usages than the policy has bits (see the tier statement in + * signature.ts). + */ +// deno-lint-ignore no-explicit-any +function collapsedPolicyClasses(): Array<[string, any]> { + return [ + ["aead-key", AeadKey], + ["cipher-key", CipherKey], + ["decryption-key", DecryptionKey], + ["encryption-key", EncryptionKey], + // Agreement keys are excluded for the other documented reason: their + // platform usages are constant, so the policy is not on the key at all. + ["secret-key", SecretKey], + ["public-key", PublicKey], + ]; +} + +/** Every class whose constructor is gated on the package-private mint token. */ +// deno-lint-ignore no-explicit-any +function tokenGatedClasses(): Array<[string, any]> { + return [ + ["signing-key", SigningKey], + ["verifying-key", VerifyingKey], + ["mac-key", MacKey], + ["kw-key", KwKey], + ...collapsedPolicyClasses(), + ]; +} + const MESSAGE = new TextEncoder().encode("polymorph-webcrypto#391 embedder key seams"); /** A non-extractable Ed25519 pair — the posture the persistence seam exists to serve. */ @@ -340,3 +412,316 @@ Deno.test("391: an injected ikm derives exactly what the platform derives", asyn "a persisted-and-reloaded ikm is the same keying material", ); }); + +// --- Round 2: the seams extended to every kind whose platform slots fully +// determine both the mint record and the WIT policy ("tier A"), and the +// constructor-provenance discipline made uniform across every +// CryptoKey-holding class. The exclusions are asserted too: a kind whose +// policy collapses onto fewer platform usages than it has WIT grants has no +// `fromCryptoKey` at all, and these tests pin that absence so it cannot be +// added by accident. + +Deno.test("391: an injected mac-key MACs exactly what the platform MACs", async () => { + const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); + const mac = MacKey.fromCryptoKey(key as CryptoKey); + + assertEq(mac.algorithmName(), "HMAC", "the injected key keeps its family"); + assertEq(mac.algorithmHash(), "SHA-256", "the mint-bound digest is read off HmacKeyAlgorithm"); + assertEq(mac.algorithmLength(), 512, "SHA-256's default HMAC key length is the block size in bits"); + assertEq(mac.canSign(), true, "the platform sign usage is the policy"); + assertEq(mac.canVerify(), true, "and so is verify"); + assertEq(mac.extractable(), false, "injection preserves non-extractability"); + + // The platform is the oracle: MAC through the wrapper, verify the tag with + // subtle over the EXTRACTED key. Agreement proves extraction hands back the + // same key rather than a lookalike. + const tag = await mac.sign(arrayStream(MESSAGE)); + const ok = await crypto.subtle.verify("HMAC", mac.toCryptoKey(), tag as Uint8Array, MESSAGE); + assertEq(ok, true, "the extracted key verifies the wrapper's tag"); + + // And the wrapper refuses a tampered tag with the detail-free verdict. + const tampered = tag.slice(); + tampered[0] ^= 0x01; + const err = await assertRejects(() => mac.verify(arrayStream(MESSAGE), tampered)); + assertEq(kindOf(err), "authentication-failed", "a tampered tag is refused"); + + // The persistence round trip: extract, structured-clone (the IndexedDB + // step), re-inject, MAC again. + const reloaded = MacKey.fromCryptoKey(structuredClone(mac.toCryptoKey())); + const again = await reloaded.sign(arrayStream(MESSAGE)); + assertEq( + again.every((b, i) => b === tag[i]), + true, + "a persisted-and-reloaded mac-key is the same key", + ); + + // SHA-1 is served by `hmac-sha1`, so an injected SHA-1 key is admitted on + // the same terms a minted one is. + const sha1 = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-1" }, false, ["sign"]); + assertEq(MacKey.fromCryptoKey(sha1 as CryptoKey).algorithmHash(), "SHA-1", "hmac-sha1's digest is served"); +}); + +Deno.test("391: mac-key injection refuses the wrong family and an unserved digest", async () => { + const aes = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + assertRefuses( + "invalid-key", + () => MacKey.fromCryptoKey(aes as CryptoKey), + "an AES-GCM key is not an HMAC key", + ); + + const pair = await ed25519Pair(); + assertRefuses( + "invalid-key", + () => MacKey.fromCryptoKey(pair.privateKey), + "a private key is not a secret key", + ); +}); + +Deno.test("391: an injected kw-key wraps and unwraps across an extract/inject cycle", async () => { + const key = await crypto.subtle.generateKey({ name: "AES-KW", length: 256 }, false, ["wrapKey", "unwrapKey"]); + const kw = KwKey.fromCryptoKey(key as CryptoKey); + + assertEq(kw.algorithmName(), "AES-KW", "the injected key keeps its family"); + assertEq(kw.algorithmLength(), 256, "the length is read off AesKeyAlgorithm"); + assertEq(kw.canWrap(), true, "the wrapKey usage is the policy"); + assertEq(kw.canUnwrap(), true, "and so is unwrapKey"); + assertEq(kw.extractable(), false, "injection preserves non-extractability"); + + // Wrap a MAC key's material with the injected wrapper, then unwrap it back + // into a mac-key through a DIFFERENT wrapper obtained by an extract/inject + // cycle. Recovering a working key proves the cycle is lossless. + const inner = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, true, ["sign"]); + const innerMac = MacKey.fromCryptoKey(inner as CryptoKey); + const wrapped = await kw.wrap(await innerMac.toWrapInputRaw()); + + const reloaded = KwKey.fromCryptoKey(structuredClone(kw.toCryptoKey())); + const recovered = await hmacSha2.unwrapKeyRaw("sha256", await reloaded.unwrap(wrapped), macOptions()); + + const expected = await innerMac.sign(arrayStream(MESSAGE)); + const got = await recovered.sign(arrayStream(MESSAGE)); + assertEq( + got.every((b, i) => b === expected[i]), + true, + "the unwrapped key is the key that was wrapped", + ); + + // A tampered wrapped blob still fails the integrity check through an + // injected key: injection does not weaken the RFC 3394 ICV. + const corrupt = wrapped.slice(); + corrupt[0] ^= 0x01; + const err = await assertRejects(() => reloaded.unwrap(corrupt)); + assertEq(kindOf(err), "authentication-failed", "a tampered blob is refused"); +}); + +Deno.test("391: kw-key injection refuses the wrong family and an unserved length", async () => { + const gcm = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + assertRefuses( + "invalid-key", + () => KwKey.fromCryptoKey(gcm as CryptoKey), + "an AES-GCM key is not a key-wrapping key", + ); + + const cbc = await crypto.subtle.generateKey({ name: "AES-CBC", length: 256 }, false, ["encrypt", "decrypt"]); + assertRefuses( + "invalid-key", + () => KwKey.fromCryptoKey(cbc as CryptoKey), + "an AES-CBC key is not a key-wrapping key", + ); + + // aes192 is declined package-wide by the WIT's portability ruling, so an + // injected 192-bit key is refused exactly as an imported one would be. + try { + const aes192 = await crypto.subtle.generateKey({ name: "AES-KW", length: 192 }, false, ["wrapKey"]); + assertRefuses( + "unsupported", + () => KwKey.fromCryptoKey(aes192 as CryptoKey), + "aes192 is declined package-wide, injection included", + ); + } catch { + // A platform that declines to mint AES-192 at all upholds the same + // boundary a step earlier; nothing to assert here. + } +}); + +Deno.test("391: an injected password derives exactly what the platform derives", async () => { + // Synthetic, labelled inputs throughout: an all-zero 16-byte "password", + // a short ASCII salt, and a small iteration count (this is a + // self-consistency check against the platform, not a work-factor test). + const passwordBytes = new Uint8Array(16); + const salt = new TextEncoder().encode("391-salt"); + const iterations = 1000; + + const key = await crypto.subtle.importKey("raw", passwordBytes, "PBKDF2", false, ["deriveBits"]); + const password = Password.fromCryptoKey(key); + assertEq(password.canDeriveBits(), true, "the platform deriveBits slot is the policy"); + assertEq(password.canDeriveKey(), false, "a slot the platform did not grant is not granted here"); + + const viaWrapper = await (await pbkdf2Sha2.prepare("sha256", password, salt, iterations)).deriveBits(256); + const viaPlatform = new Uint8Array( + await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256), + ); + assertEq( + viaWrapper.every((b, i) => b === viaPlatform[i]), + true, + "the injected password derives the platform's answer", + ); + + // Persistence round trip. + const reloaded = Password.fromCryptoKey(structuredClone(password.toCryptoKey())); + const again = await (await pbkdf2Sha2.prepare("sha256", reloaded, salt, iterations)).deriveBits(256); + assertEq( + again.every((b, i) => b === viaPlatform[i]), + true, + "a persisted-and-reloaded password is the same keying material", + ); +}); + +Deno.test("391: password injection refuses a key of another derivation family", async () => { + const hkdfKey = await crypto.subtle.importKey("raw", new Uint8Array(32), "HKDF", false, ["deriveBits"]); + assertRefuses( + "invalid-key", + () => Password.fromCryptoKey(hkdfKey), + "an HKDF key is not a PBKDF2 password", + ); + + const aes = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt"]); + assertRefuses( + "invalid-key", + () => Password.fromCryptoKey(aes as CryptoKey), + "an AES key is not a PBKDF2 password", + ); + + // The mirror of the ikm case: Password and Ikm are distinct kinds and + // neither admits the other's key. + const pbkdf2Key = await crypto.subtle.importKey("raw", new Uint8Array(16), "PBKDF2", false, ["deriveBits"]); + assertRefuses( + "invalid-key", + () => Ikm.fromCryptoKey(pbkdf2Key), + "a PBKDF2 password is not HKDF input keying material", + ); +}); + +Deno.test("391: an injected RSASSA-PKCS1-v1_5 pair signs, verifies, and reports its platform record", async () => { + const pair = await rsassaPair(2048, "SHA-256"); + const signing = SigningKey.fromCryptoKey(pair.privateKey); + const verifying = VerifyingKey.fromCryptoKey(pair.publicKey); + + // The mint-bound record is rebuilt from the platform slots, through the + // family's own record builder — so the getters report the real key. + assertEq(signing.algorithmName(), "RSASSA-PKCS1-v1_5", "the family is read off the key"); + assertEq(signing.algorithmHash(), "SHA-256", "the digest is read off RsaHashedKeyAlgorithm"); + assertEq(signing.algorithmLength(), 2048, "the modulus length is read off the key"); + assertEq(verifying.algorithmHash(), "SHA-256", "the public half agrees"); + assertEq(verifying.algorithmLength(), 2048, "and on the modulus length"); + const e = signing.algorithmPublicExponent(); + assertEq(e === undefined ? -1 : e.length, 3, "the public exponent is the platform's own 3-octet value"); + + const sig = await signing.sign(arrayStream(MESSAGE)); + assertEq(sig.length, 256, "a 2048-bit RSA signature is one modulus wide"); + await verifying.verify(arrayStream(MESSAGE), sig); + + // Platform oracle in both directions. + const platformSig = new Uint8Array( + await crypto.subtle.sign("RSASSA-PKCS1-v1_5", signing.toCryptoKey(), MESSAGE), + ); + await verifying.verify(arrayStream(MESSAGE), platformSig); + + const tampered = sig.slice(); + tampered[0] ^= 0x01; + const err = await assertRejects(() => verifying.verify(arrayStream(MESSAGE), tampered)); + assertEq(kindOf(err), "authentication-failed", "a tampered RSA signature is refused"); +}); + +Deno.test("391: RSASSA injection applies the mint paths' own admission windows", async () => { + // The signing interfaces use a TIGHTER modulus window (2048-8192) than the + // verifying ones (1024-16384), because verification is a public operation + // over an attacker-supplied key. Injection inherits both windows, so a + // 1024-bit key is admissible as a verifying key and refused as a signing + // key — the asymmetry is deliberate, not an oversight. + const small = await rsassaPair(1024, "SHA-256"); + assertRefuses( + "invalid-key", + () => SigningKey.fromCryptoKey(small.privateKey), + "a 1024-bit modulus is below the signing window", + ); + assertEq( + VerifyingKey.fromCryptoKey(small.publicKey).algorithmLength(), + 1024, + "the same modulus is inside the verifying window", + ); + + // SHA-1 is deliberately absent from the RSA families' variant table, so a + // SHA-1-bound RSASSA key is refused however it was minted. + try { + const sha1 = await rsassaPair(2048, "SHA-1"); + assertRefuses( + "unsupported", + () => VerifyingKey.fromCryptoKey(sha1.publicKey), + "the RSA families do not serve SHA-1", + ); + } catch { + // A platform that will not mint SHA-1 RSASSA at all upholds the same + // boundary earlier. + } +}); + +Deno.test("391: the excluded families keep their named refusal", async () => { + const ecdsa = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign", "verify"], + ) as CryptoKeyPair; + const err = assertThrows(() => SigningKey.fromCryptoKey(ecdsa.privateKey)); + assertEq(kindOf(err), "unsupported", "ECDSA stays outside the injection boundary"); + const detail = ((err as ComponentException).payload as { value: string }).value; + assertTrue( + detail.includes("mint bindings") && detail.includes("invent"), + "the refusal names the reason: the per-mint binding is not on the key", + ); + + const pss = await crypto.subtle.generateKey( + { name: "RSA-PSS", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + false, + ["sign", "verify"], + ) as CryptoKeyPair; + const pssErr = assertThrows(() => SigningKey.fromCryptoKey(pss.privateKey)); + assertEq(kindOf(pssErr), "unsupported", "RSA-PSS stays outside too: its salt length is a mint choice"); +}); + +Deno.test("391: kinds whose policy collapses onto fewer usages expose no injection seam", () => { + // These are the documented exclusions (see the tier statement in + // signature.ts). The absence is asserted rather than assumed, so a future + // change that adds a seam without resolving the policy-collapse question + // fails here instead of silently widening an injected key's authority. + for (const [name, cls] of collapsedPolicyClasses()) { + assertTrue( + !("fromCryptoKey" in cls), + `${name} must expose no fromCryptoKey: its WIT grants collapse onto fewer platform usages`, + ); + assertTrue( + !("toCryptoKey" in cls.prototype), + `${name} must expose no toCryptoKey either: no seam means no half of one`, + ); + } +}); + +Deno.test("391: every CryptoKey-holding class refuses construction outside its minting interfaces", () => { + // One sweep over the whole token-gated roster: the construction token is + // package-private and unexported from mod.ts, so no argument a consumer can + // build satisfies any of these guards. + for (const [name, cls] of tokenGatedClasses()) { + // deno-lint-ignore no-explicit-any + const err = assertThrows(() => new (cls as any)(), `${name} must refuse a bare construction`); + assertEq(kindOf(err), "other", `${name} refuses with the provenance verdict`); + const detail = ((err as ComponentException).payload as { value: string }).value; + assertTrue( + detail.includes("constructed outside its minting interfaces"), + `${name} uses the package's provenance phrasing, got: ${detail}`, + ); + + // A forged token is no better than none: identity is the mechanism. + // deno-lint-ignore no-explicit-any + const forged = assertThrows(() => new (cls as any)(Symbol("polymorph:webcrypto mint"), {}, {}, {})); + assertEq(kindOf(forged), "other", `${name} refuses a same-description forged symbol`); + } +});