Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions js/polyengine/src/aead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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. */
Expand All @@ -258,22 +268,22 @@ 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<AeadKey> => {
const bits = aesBits(variant);
const policy = optionsOf(options);
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<AeadKey> => {
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<AeadKey> => {
const { bytes } = consumeUnwrapInput(input);
Expand Down
20 changes: 15 additions & 5 deletions js/polyengine/src/cipher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<CipherKey> {
Expand All @@ -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<CipherKey> {
Expand All @@ -283,15 +293,15 @@ 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<CipherKey> {
const policy = cipherPolicyOf(options);
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<CipherKey> {
Expand Down
13 changes: 7 additions & 6 deletions js/polyengine/src/ecdh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -67,7 +68,7 @@ export const ecdh = {
true,
[],
);
return new PublicKey(key);
return new PublicKey(MINT, key);
},

importPublicKeySpki: async (variant: string, spki: Uint8Array): Promise<PublicKey> => {
Expand All @@ -82,7 +83,7 @@ export const ecdh = {
true,
[],
);
return new PublicKey(key);
return new PublicKey(MINT, key);
},

importPublicKeyJwk: async (variant: string, jwkText: string): Promise<PublicKey> => {
Expand All @@ -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<SecretKey> => {
Expand All @@ -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 (
Expand All @@ -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]> => {
Expand All @@ -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<SecretKey> => {
Expand Down
43 changes: 18 additions & 25 deletions js/polyengine/src/hkdf.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}
}

Expand Down
107 changes: 104 additions & 3 deletions js/polyengine/src/internal.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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`);
}
}
Loading
Loading