diff --git a/.github/justfile b/.github/justfile index 927c996..c4ddd02 100644 --- a/.github/justfile +++ b/.github/justfile @@ -62,8 +62,6 @@ core: @just gha::_step test-translate @just gha::_step conformance @just gha::_step sched-seeds - @just gha::_step test-ports - @just gha::_step test-webrtc # The post-merge browser job (ci.yml `browser`; gates the prerelease). browser: diff --git a/AGENTS.md b/AGENTS.md index caf3c03..daef93d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,11 +43,8 @@ just publish-check # deno publish --dry-run: the JSR publish checks, no u just examples test-translate # embedder examples; build-time translation CLI just conformance # official CM suite, Deno lane just sched-seeds # seeded-shuffle reruns: DELTIC_SCHED_SEED=1, =4242 (FIFO when unset) -just test-ports # ports/webcrypto + ports/websocket unit suites -just test-webrtc # ports/webrtc unit suite just shells # pinned engine/runtime lanes: sm + node everywhere, jsc on x64, bun findings-only just browsers # chromium + firefox lanes (`just browsers-install` once) -just websocket-conformance # their suite under this host (spawns their echod) just smoke-tls # polymorph-tls suite (issue #18) just smoke-c0 # C0 smoke legs ``` diff --git a/README.md b/README.md index a13a5f7..77e6bbe 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,6 @@ Pre-1.0, but densely gated: | `ct-runner/` | conformance-suite runner for the polymorph-test L1 contract | | `harness/` + `tools/browser` | official-suite harness; Deno lane + Chromium/Firefox/WebKit lanes | | `contracts/` | the versioned interface contracts (plan format, embedder API, intrinsics, digest) | -| `ports/` | polymorph host-module ports (reference implementations pending upstreaming; the consumer exams retired upstream — docs/consumers.md) | ## Quick start diff --git a/docs/consumers.md b/docs/consumers.md index e4c554c..bcde013 100644 --- a/docs/consumers.md +++ b/docs/consumers.md @@ -107,7 +107,8 @@ Node is **not a consumer requirement.** Deno functionally substitutes across the whole consumer capability surface — verified empirically (2026-08-08, Deno 2.9.5/linux-arm64, via the C0 capability probe; served and retired 2026-08-14, history: `git log -- tools/probes/webrtc-deno` — the living -WebRTC coverage is `just test-webrtc`, node-datachannel under Deno): +WebRTC-under-Deno coverage is the consumers' own deltic legs, e.g. the +webrtc driver-ct matrix under their upstreamed host module): | Capability | Deno path | Status | |---|---|---| @@ -130,9 +131,7 @@ the in-repo references is | Path | What | Gate | |---|---|---| -| `ports/websocket` | `polymorph:websocket/connections` host module | their conformance suite 55/55 incl. TLS (`conformance/run.ts`); their deltic-deno + deltic-browser rows run the upstreamed copy | -| `ports/webcrypto` | `polymorph:webcrypto` host module (full surface — [#3](https://github.com/lann/deltic/issues/3) closed) | KATs vs their vectors + iroh exec-model integration | -| `ports/webrtc` | `polymorph:webrtc-datachannels/connections` host module | their echo-demo component over real data channels; their full driver-ct loopback matrix (solo+pair, 37/37) runs under the upstreamed copy ([polymorph-webrtc-datachannels#149](https://github.com/polymorph-components/polymorph-webrtc-datachannels/pull/149)) | +| `ports/{websocket,webcrypto,webrtc}` (retired) | the `polymorph:{websocket/connections,webcrypto,webrtc-datachannels/connections}` host modules | served and retired (2026-08-14): developed here as the reference implementations of the embedder conventions, upstreamed as the consumers' own host modules — websocket ([#40](https://github.com/polymorph-components/polymorph-websocket/pull/40)/[#41](https://github.com/polymorph-components/polymorph-websocket/pull/41)), webcrypto ([#352](https://github.com/polymorph-components/polymorph-webcrypto/pull/352)), webrtc ([#149](https://github.com/polymorph-components/polymorph-webrtc-datachannels/pull/149) — their driver-ct loopback matrix 37/37) — whose repos run their suites (incl. websocket's 55/55 conformance) under those copies as the living gates; the in-repo `test-ports`/`test-webrtc`/`websocket-conformance` recipes retired with the trees. History: `git log -- ports` | | `exams/iroh-endpoint` (retired) | the endpoint exit exam | served and retired (2026-08-11, in-repo through 5/5 + IROH-1): upstreamed as their `host-deltic/` + `just exam-deltic` ([polymorph-iroh#36](https://github.com/polymorph-components/polymorph-iroh/pull/36), merged), after which the in-repo copy was re-testing deltic against a staling guest snapshot — the consumer's own deltic leg is the living exam; the runtime defects it caught are pinned in deltic's suites (R-1/R-2, entry-deferral). History: `git log -- exams/iroh-endpoint` | | `ct-runner` | L3 runner for the polymorph-test L1 contract | golden-tested L4 JSONL; drives the websocket suite | | `tools/smoke-c0` | C0 smoke legs + report | legs 1–4 (`REPORT.md`) | diff --git a/justfile b/justfile index 2dc5340..00bbb5a 100644 --- a/justfile +++ b/justfile @@ -16,7 +16,7 @@ ci: (gha::core) (gha::browser) # Includes the consumer smokes CI cannot run (they need the polymorph # checkouts; docs/consumers.md). # The full pre-commit pass (AGENTS.md "Gates"): everything. -gates: build test-rust test-protocol test-runtime test-wasi-shims test-sockets-node test-ct-runner test-bundle publish-check examples test-translate conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 +gates: build test-rust test-protocol test-runtime test-wasi-shims test-sockets-node test-ct-runner test-bundle publish-check examples test-translate conformance sched-seeds shells browsers smoke-tls smoke-c0 # Fast sanity: builds + native tests + type-checks, no suites. check: build test-rust @@ -135,18 +135,6 @@ sched-seeds: shim fixtures corpus cd runtime && DELTIC_SCHED_SEED=4242 deno task test cd harness && DELTIC_SCHED_SEED=1 deno task conformance -# Consumer conformance legs are separate (`websocket-conformance` below). -# Ports unit suites. -test-ports: - cd ports/webcrypto && deno test --allow-read tests/ - cd ports/websocket && deno task test - -# node-datachannel is a Node-API addon with linux prebuilds for both x64 -# and arm64. -# webrtc unit suite. -test-webrtc: - cd ports/webrtc && deno install --allow-scripts=npm:node-datachannel && deno test -A webrtc.test.ts - # ----- engine lanes ----------------------------------------------------------- # Pinned lanes (sm-pinned, jsc-pinned) are required gates — a deviation @@ -217,11 +205,6 @@ smoke-tls: shim smoke-c0: shim cd tools/smoke-c0 && deno task leg1 && deno task leg2 && deno task leg3 && deno task leg4 -# Spawns their echod; DENO_CERT rides the task definition. -# The consumer's REAL websocket conformance suite under this host. -websocket-conformance: shim - cd ports/websocket && deno task conformance - # The host-boundary microbench (bench/boundary/README.md): calls/sec per # ABI shape for the CURRENT tree, on plain node (callback + jspi) and # deno. Manual instrument, not a gate — numbers are box-relative; the diff --git a/ports/webcrypto/conformance/run.ts b/ports/webcrypto/conformance/run.ts deleted file mode 100644 index dd75093..0000000 --- a/ports/webcrypto/conformance/run.ts +++ /dev/null @@ -1,167 +0,0 @@ -// The flagship gate: execute the consumer's REAL conformance suites -// (`polymorph-webcrypto/conformance/guest-ct` and its signing sibling) -// under deltic, with this port supplying every `polymorph:webcrypto/*` -// interface and `wasi-shims` supplying WASI. -// -// deno task conformance [--only SUBSTRING] [--suite shared|signing] [--jspi] -// -// Shape follows ports/websocket/conformance/run.ts and -// tools/smoke-tls/run.ts. Two suites, two envelope targets: -// -// suite | wasm | target key | missing -// ---------+-----------------------------------+-----------------+---------------- -// shared | conformance_guest_ct | deltic | ["sha1-checked"] -// signing | conformance_signing_guest_ct | deltic-signing | (see below) -// -// `missing` is the feature-tag scheduling list (ct-runner's `missing`, -// upstream harness.mjs's): the features THIS target lacks, so cases gated -// on them are scheduled out as `not-applicable` instead of failing. -// `sha1-checked` is the one standing entry — no platform-backed provider -// can serve it (src/sha1Checked.ts) — and the suite's `!sha1-checked` -// decline case still runs, asserting the refusal works. -// -// NOTHING here writes into the consumer tree: it only reads the prebuilt -// suite artifacts, and results are written under this directory. - -import { Translator } from "../../../runtime/src/shim/mod.ts"; -import type { ComponentArtifacts } from "../../../runtime/src/embedder/mod.ts"; -import { runSuite } from "../../../ct-runner/src/mod.ts"; -import { wasiShims } from "../../../wasi-shims/src/mod.ts"; -import { setRsaPrivateKeyPolicy, webcryptoImports } from "../src/mod.ts"; - -const CE_ROOT = new URL("../../../", import.meta.url).pathname; -const CONSUMER = "/home/lmartin/p/polymorph/polymorph-webcrypto"; -const SUITE_DIR = `${CONSUMER}/target/wasm32-wasip2/release`; - -/** harness.mjs:33's per-case wall bound. */ -const CASE_TIMEOUT_MS = 60_000; - -interface SuiteSpec { - /** The lock identity: the wasm file stem (ct-runner normalizes `-` to `_`). */ - name: string; - wasm: string; - target: string; - missing: string[]; -} - -const SUITES: Readonly> = { - shared: { - name: "conformance-guest-ct", - wasm: `${SUITE_DIR}/conformance_guest_ct.wasm`, - target: "deltic", - // No platform WebCrypto carries sha1dc; the port declines the - // interface fail-closed (src/sha1Checked.ts). - missing: ["sha1-checked"], - }, - signing: { - name: "conformance-signing-guest-ct", - wasm: `${SUITE_DIR}/conformance_signing_guest_ct.wasm`, - target: "deltic-signing", - // Deno's `crypto.subtle` serves the gated RSA private-key mints, so - // this port serves them too (see src/rsaSignature.ts's posture note): - // nothing is declared missing here. - missing: [], - }, -}; - -interface Cli { - /** - * `--rsa-private decline`: take the reference's BROWSER posture — the - * gated RSA private-key interfaces (`rsa-pss-sign`, - * `rsassa-pkcs1-v15-sign`, `rsa-oaep-decrypt`) refuse with - * `error.unsupported` — and declare the two matching features missing, - * so the suite schedules their cases out and runs the `!rsa-sign` / - * `!rsa-oaep-decrypt` decline cases instead. Default is `serve` - * (src/rsaSignature.ts's posture note). - */ - rsaPrivate: "serve" | "decline"; - only?: string; - jspi: boolean; - out?: string; - suites: string[]; - missing?: string[]; -} - -function parseArgs(argv: string[]): Cli { - const cli: Cli = { jspi: false, suites: [], rsaPrivate: "serve" }; - for (let i = 0; i < argv.length; i++) { - switch (argv[i]) { - case "--only": - cli.only = argv[++i]; - break; - case "--jspi": - cli.jspi = true; - break; - case "--out": - cli.out = argv[++i]; - break; - case "--suite": - cli.suites.push(argv[++i]); - break; - case "--rsa-private": { - const value = argv[++i]; - if (value !== "serve" && value !== "decline") { - throw new Error(`--rsa-private takes "serve" or "decline", got ${value}`); - } - cli.rsaPrivate = value; - break; - } - case "--missing": - cli.missing = argv[++i].split(",").filter((s) => s.length > 0); - break; - default: - throw new Error(`unknown argument ${argv[i]}`); - } - } - if (cli.suites.length === 0) cli.suites = ["shared", "signing"]; - for (const s of cli.suites) { - if (!(s in SUITES)) throw new Error(`unknown suite '${s}' (known: ${Object.keys(SUITES).join(", ")})`); - } - return cli; -} - -async function loadArtifacts(wasm: string): Promise { - const shim = `${CE_ROOT}target/wasm32-unknown-unknown/release/translator_shim.wasm`; - const translator = await Translator.create(await Deno.readFile(shim)); - const componentBytes = await Deno.readFile(wasm); - const { plan, adapters } = translator.translate(componentBytes); - return { plan, componentBytes, adapters }; -} - -async function runOne(spec: SuiteSpec, cli: Cli): Promise { - const artifacts = await loadArtifacts(spec.wasm); - const imports = { ...wasiShims({ cli: { env: {}, passthrough: false } }), ...webcryptoImports() }; - const out = cli.out ?? new URL(`./results-${spec.target}.jsonl`, import.meta.url).pathname; - const lines: string[] = []; - const started = performance.now(); - const counts = await runSuite(artifacts, { - imports, - target: spec.target, - suiteName: spec.name, - only: cli.only, - missing: cli.missing ?? - (cli.rsaPrivate === "decline" - ? [...spec.missing, "rsa-sign", "rsa-oaep-decrypt"] - : spec.missing), - caseTimeoutMs: CASE_TIMEOUT_MS, - jspi: cli.jspi, - emit: (line) => lines.push(line), - log: (msg) => console.error(msg), - }); - await Deno.writeTextFile(out, lines.join("\n") + "\n"); - console.error( - `\n[${spec.target}] ${counts.passed} passed | ${counts.failed} failed | ${counts.skipped} skipped | ` + - `${counts.na} n/a (${counts.total} total) in ${((performance.now() - started) / 1000).toFixed(1)}s -> ${out}`, - ); - return counts.failed; -} - -async function main() { - const cli = parseArgs(Deno.args); - setRsaPrivateKeyPolicy(cli.rsaPrivate); - let failed = 0; - for (const key of cli.suites) failed += await runOne(SUITES[key], cli); - if (failed > 0) Deno.exitCode = 1; -} - -if (import.meta.main) await main(); diff --git a/ports/webcrypto/deno.json b/ports/webcrypto/deno.json deleted file mode 100644 index 4c1d190..0000000 --- a/ports/webcrypto/deno.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@deltic/port-webcrypto", - "version": "0.0.0", - "exports": "./src/mod.ts", - "imports": { - "@deltic/runtime/embedder": "../../runtime/src/embedder/mod.ts" - }, - "tasks": { - "test": "deno test --allow-read tests/", - "check": "deno check src tests conformance", - "conformance": "deno run --allow-read --allow-write --allow-env conformance/run.ts" - } -} diff --git a/ports/webcrypto/src/aead.ts b/ports/webcrypto/src/aead.ts deleted file mode 100644 index 2e9ca08..0000000 --- a/ports/webcrypto/src/aead.ts +++ /dev/null @@ -1,288 +0,0 @@ -// `polymorph:webcrypto/aead` (algorithm-agnostic AEAD resources) plus -// `aes-gcm` — wit/webcrypto.wit `interface aead`, wit/aes.wit `interface -// aes-gcm`. `aes-cbc`/`aes-ctr`/`aes-kw` (the `cipher`/`key-wrap` kinds) are -// NOT ported (time-boxed scope cut — see the mission report's coverage -// table, not a Deno `crypto.subtle` gap: Deno serves AES-CBC/CTR too). - -import { decryptFailure, errInvalidKey, errInvalidNonce, errNotExtractable, errNotPermitted, errOther, errUnsupported, notPermitted, platformCall } from "./errors.ts"; -import { importPlatformKeyJwk, jwkKeyBytes, jwkMaterial, requireStrictBase64url } from "./platform.ts"; -import { asBufferSource, collectByteStream, unwrappedJwk } from "./util.ts"; -import { deriveKeyFrom, type DeriveInput } from "./derivation.ts"; -import { - consumeUnwrapInput, - consumeWrapInput, - UnwrapInput, - type WrapInput as WrapInputT, - WrapInput, -} from "./wrapping.ts"; -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; - -const subtle = globalThis.crypto.subtle; - -interface AeadPolicy { - seal: boolean; - open: boolean; - wrap: boolean; - unwrap: boolean; - extractable: boolean; -} - -const optionsState = new WeakMap(); - -function optionsOf(o: AeadKeyOptions): AeadPolicy { - const p = optionsState.get(o); - if (p === undefined) errOther("aead-key-options minted by another provider"); - return p; -} - -/** `aead.aead-key-options`. */ -export class AeadKeyOptions { - constructor() { - optionsState.set(this, { seal: false, open: false, wrap: false, unwrap: false, extractable: false }); - } - canSeal(allowed: boolean): void { - optionsOf(this).seal = allowed; - } - canOpen(allowed: boolean): void { - optionsOf(this).open = allowed; - } - canWrap(allowed: boolean): void { - optionsOf(this).wrap = allowed; - } - canUnwrap(allowed: boolean): void { - optionsOf(this).unwrap = allowed; - } - extractable(allowed: boolean): void { - optionsOf(this).extractable = allowed; - } -} - -/** - * The platform usages a mint needs: `wrap` runs `subtle.encrypt` and - * `unwrap` runs `subtle.decrypt` (WebCrypto's `wrapKey`-with-an- - * encryption-algorithm model), so the platform key carries `encrypt` if - * (seal or wrap) and `decrypt` if (open or unwrap); the WIT grants are - * enforced host-side against `grants` (reference: js/jco/webcrypto.js - * `aeadKeyGrants`, lines 595-607). - */ -function platformUsages(policy: AeadPolicy): KeyUsage[] { - const usages: KeyUsage[] = []; - if (policy.seal || policy.wrap) usages.push("encrypt"); - if (policy.open || policy.unwrap) usages.push("decrypt"); - if (usages.length === 0) errNotPermitted("a key with no enabled usage cannot be minted"); - return usages; -} - -const GCM_TAG_BITS = new Set([32, 64, 96, 104, 112, 120, 128]); - -function gcmTagLengthBits(tagSize: number | undefined): number { - if (tagSize === undefined) return 128; - const bits = tagSize * 8; - if (!GCM_TAG_BITS.has(bits)) { - errUnsupported(`AES-GCM tag size ${tagSize} bytes is outside the registry's 32-128 bit set`); - } - return bits; -} - -/** `aead.aead-key`: an AES-GCM key bound to a length at mint. */ -export class AeadKey { - #key: CryptoKey; - #lengthBits: number; - #grants: AeadPolicy; - - constructor(key: CryptoKey, lengthBits: number, grants: AeadPolicy) { - this.#key = key; - this.#lengthBits = lengthBits; - this.#grants = { ...grants }; - } - - algorithmName(): string { - return this.#key.algorithm.name; - } - algorithmLength(): number { - return this.#lengthBits; - } - nonceSize(): number { - return 12; - } - tagSize(): number { - return 16; - } - extractable(): boolean { - return this.#key.extractable; - } - canSeal(): boolean { - return this.#grants.seal; - } - canOpen(): boolean { - return this.#grants.open; - } - canWrap(): boolean { - return this.#grants.wrap; - } - canUnwrap(): boolean { - return this.#grants.unwrap; - } - - async #sealOpen( - direction: "seal" | "open", - nonce: Uint8Array, - aad: Uint8Array, - tagSize: number | undefined, - message: Uint8Array, - operation: string = direction, - ): Promise { - if (nonce.length < 12 || nonce.length > 128) { - errInvalidNonce(`AES-GCM nonce must be 12 to 128 bytes, got ${nonce.length}`); - } - const params: AesGcmParams = { - name: "AES-GCM", - iv: asBufferSource(nonce), - additionalData: asBufferSource(aad), - tagLength: gcmTagLengthBits(tagSize), - }; - if (direction === "seal") { - const out = await platformCall(`AES-GCM ${operation}`, () => - subtle.encrypt(params, this.#key, asBufferSource(message))); - return new Uint8Array(out); - } - try { - const out = await subtle.decrypt(params, this.#key, asBufferSource(message)); - return new Uint8Array(out); - } catch (err) { - decryptFailure(err, operation); - } - } - - async seal(nonce: Uint8Array, aad: Uint8Array, tagSize: number | undefined, plaintext: Stream): Promise { - const message = await collectByteStream(plaintext); - if (!this.canSeal()) notPermitted("seal"); - return this.#sealOpen("seal", nonce, aad, tagSize, message); - } - - async open(nonce: Uint8Array, aad: Uint8Array, tagSize: number | undefined, ciphertext: Stream): Promise { - const message = await collectByteStream(ciphertext); - if (!this.canOpen()) notPermitted("open"); - return this.#sealOpen("open", nonce, aad, tagSize, message); - } - - async wrap(nonce: Uint8Array, aad: Uint8Array, tagSize: number | undefined, input: WrapInputT): Promise { - const { bytes } = consumeWrapInput(input); - if (!this.canWrap()) notPermitted("wrap"); - return this.#sealOpen("seal", nonce, aad, tagSize, bytes, "wrap"); - } - - async unwrap( - nonce: Uint8Array, - aad: Uint8Array, - tagSize: number | undefined, - wrapped: Uint8Array, - ): Promise { - if (!this.canUnwrap()) notPermitted("unwrap"); - const plaintext = await this.#sealOpen("open", nonce, aad, tagSize, wrapped, "unwrap"); - return new UnwrapInput(plaintext); - } - - async exportKeyRaw(): Promise { - if (!this.#key.extractable) { - errNotExtractable(); - } - const raw = await platformCall("export raw", () => subtle.exportKey("raw", this.#key)); - return new Uint8Array(raw); - } - async exportKeyJwk(): Promise { - if (!this.#key.extractable) { - errNotExtractable(); - } - const jwk = await platformCall("export jwk", () => subtle.exportKey("jwk", this.#key)); - return JSON.stringify(jwk); - } - async toWrapInputRaw(): Promise { - return new WrapInput("raw", await this.exportKeyRaw()); - } - async toWrapInputJwk(): Promise { - const jwk = await this.exportKeyJwk(); - return new WrapInput("jwk", new TextEncoder().encode(jwk)); - } -} - -/** The `polymorph:webcrypto/aead@0.1.0` interface: its resource classes. */ -export const aead = { AeadKey, AeadKeyOptions }; - -const AES_BITS: Readonly> = Object.freeze({ - aes128: 128, - // aes192 is declined package-wide (wit/aes.wit `aes-variant` doc): no - // major browser engine serves it, and Deno's V8-backed WebCrypto follows - // suit — this is the port honoring the WIT's own portability ruling, not - // an incidental Deno gap. - aes256: 256, -}); - -function aesBits(variant: string): number { - const bits = AES_BITS[variant]; - if (bits === undefined) errUnsupported(`${variant} is not served by this implementation`); - return bits; -} - -async function importAesGcmKey(bits: number, raw: Uint8Array, options: AeadKeyOptions): Promise { - const policy = optionsOf(options); - if (raw.length * 8 !== bits) { - errInvalidKey(`AES-GCM key must be ${bits / 8} bytes for the declared variant, got ${raw.length}`); - } - 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); -} - -/** The `polymorph:webcrypto/aes-gcm@0.1.0` interface. */ -export const aesGcm = { - importKeyRaw: (variant: string, raw: Uint8Array, options: AeadKeyOptions) => - importAesGcmKey(aesBits(variant), raw, options), - importKeyJwk: async (variant: string, jwk: string, options: AeadKeyOptions): Promise => { - const bits = aesBits(variant); - const policy = optionsOf(options); - const usages = platformUsages(policy); - // `jwkMaterial` strips the consumer-policy members and every platform - // refusal becomes `invalid-key` (reference: js/jco/webcrypto.js:2570). - const material = jwkMaterial(jwk); - requireStrictBase64url(material.k); - const key = await importPlatformKeyJwk( - `${variant} JWK`, - material, - { name: "AES-GCM", length: bits }, - policy.extractable, - usages, - ); - const gotBits = jwkKeyBytes(material.k) * 8; - if (gotBits !== bits) { - errInvalidKey(`JWK carries a ${gotBits}-bit key; ${variant} requires ${bits}`); - } - return new AeadKey(key, bits, policy); - }, - generateKey: async (variant: string, options: AeadKeyOptions): Promise => { - 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); - }, - 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); - }, - unwrapKeyRaw: (variant: string, input: UnwrapInput, options: AeadKeyOptions) => { - const { bytes } = consumeUnwrapInput(input); - return importAesGcmKey(aesBits(variant), bytes, options); - }, - unwrapKeyJwk: (variant: string, input: UnwrapInput, options: AeadKeyOptions) => { - const { bytes } = consumeUnwrapInput(input); - const policy = optionsOf(options); - const jwk = unwrappedJwk(bytes, "enc", platformUsages(policy)); - return aesGcm.importKeyJwk(variant, jwk, options); - }, -}; diff --git a/ports/webcrypto/src/cipher.ts b/ports/webcrypto/src/cipher.ts deleted file mode 100644 index 9325980..0000000 --- a/ports/webcrypto/src/cipher.ts +++ /dev/null @@ -1,316 +0,0 @@ -// `polymorph:webcrypto/cipher` (the algorithm-agnostic unauthenticated -// cipher resources) plus `aes-cbc` / `aes-ctr` — wit/encryption.wit -// `interface cipher`, wit/aes.wit. -// -// Behavioral reference: js/jco/webcrypto.js:2667-3091 (`CipherKeyOptions`, -// `cipherParams`, `CipherKey`, `cipherMinting`). Nothing here authenticates: -// the WIT's Security notes make every malformed-input failure ONE uniform -// `error.other`, because a distinguishable padding verdict is a -// padding-oracle amplifier. - -import { - errInvalidKey, - errInvalidNonce, - errNotPermitted, - errOther, - notPermitted, - platformCall, -} from "./errors.ts"; -import { asBufferSource, collectByteStream, unwrappedJwk } from "./util.ts"; -import { - aesVariantByteLength, - exportJwkGated, - exportRawGated, - importPlatformKey, - importPlatformKeyJwk, - jwkKeyBytes, - jwkMaterial, - redactingInvalidKey, - requireStrictBase64url, -} from "./platform.ts"; -import { type DeriveInput, deriveKeyFrom } from "./derivation.ts"; -import { consumeUnwrapInput, consumeWrapInput, UnwrapInput, WrapInput } from "./wrapping.ts"; -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; - -const subtle = globalThis.crypto.subtle; - -export type CipherName = "AES-CBC" | "AES-CTR"; - -interface CipherPolicy { - encrypt: boolean; - decrypt: boolean; - wrap: boolean; - unwrap: boolean; - extractable: boolean; -} - -const cipherPolicies = new WeakMap(); - -function cipherPolicyOf(o: CipherKeyOptions): CipherPolicy { - const p = cipherPolicies.get(o); - if (p === undefined) errOther("cipher-key-options minted by another provider"); - return p; -} - -/** `cipher.cipher-key-options` (reference: webcrypto.js:2672). */ -export class CipherKeyOptions { - constructor() { - cipherPolicies.set(this, { - encrypt: false, - decrypt: false, - wrap: false, - unwrap: false, - extractable: false, - }); - } - canEncrypt(allowed: boolean): void { - cipherPolicyOf(this).encrypt = allowed; - } - canDecrypt(allowed: boolean): void { - cipherPolicyOf(this).decrypt = allowed; - } - canWrap(allowed: boolean): void { - cipherPolicyOf(this).wrap = allowed; - } - canUnwrap(allowed: boolean): void { - cipherPolicyOf(this).unwrap = allowed; - } - extractable(allowed: boolean): void { - cipherPolicyOf(this).extractable = allowed; - } -} - -/** - * The platform usages a cipher mint needs (reference: webcrypto.js:2719): - * `wrap` runs `subtle.encrypt` and `unwrap` runs `subtle.decrypt`, so the - * WIT grants do not map one-to-one and are enforced host-side instead. - */ -function cipherUsages(policy: CipherPolicy): KeyUsage[] { - const usages: KeyUsage[] = []; - if (policy.encrypt || policy.wrap) usages.push("encrypt"); - if (policy.decrypt || policy.unwrap) usages.push("decrypt"); - if (usages.length === 0) errNotPermitted("a key with no enabled usage cannot be minted"); - return usages; -} - -/** The granted operations' platform names, for the unwrap-path `key_ops` rule (reference: webcrypto.js:2749). */ -function cipherGrantedOps(policy: CipherPolicy): string[] { - const ops: string[] = []; - if (policy.encrypt) ops.push("encrypt"); - if (policy.decrypt) ops.push("decrypt"); - if (policy.wrap) ops.push("wrapKey"); - if (policy.unwrap) ops.push("unwrapKey"); - return ops; -} - -/** - * Validate a per-call IV/counter-length pair against the key's mode and - * build the platform params (reference: webcrypto.js:2767). AES-CBC - * rejects a supplied counter length and AES-CTR rejects its absence — - * both `error.invalid-nonce`, as the WIT pins. - */ -function cipherParams(name: CipherName, iv: Uint8Array, counterLength: number | undefined): AesCbcParams | AesCtrParams { - if (name === "AES-CBC" && counterLength !== undefined) { - errInvalidNonce("AES-CBC takes no counter length"); - } - if (name === "AES-CTR") { - if (counterLength === undefined) { - errInvalidNonce("AES-CTR requires a counter length"); - } - if (counterLength === 0 || counterLength > 128) { - errInvalidNonce(`the counter length must be 1 to 128 bits, got ${counterLength}`); - } - } - if (iv.length !== 16) { - errInvalidNonce(`${name} requires a 16-byte IV, got ${iv.length} bytes`); - } - return name === "AES-CBC" - ? { name, iv: asBufferSource(iv) } - : { name, counter: asBufferSource(iv), length: counterLength as number }; -} - -/** - * `cipher.cipher-key` (reference: webcrypto.js:2805). `encrypt`/`decrypt` - * return the ciphertext/plaintext as a `stream`: this port lowers a - * one-chunk producer (contracts/embedder-api.md §"Streams and futures" — - * the lowering layer accepts an iterable of chunks and owns the pumping). - */ -export class CipherKey { - #key: CryptoKey; - #name: CipherName; - #lengthBits: number; - #grants: CipherPolicy; - - constructor(key: CryptoKey, name: CipherName, lengthBits: number, grants: CipherPolicy) { - this.#key = key; - this.#name = name; - this.#lengthBits = lengthBits; - this.#grants = { ...grants }; - } - - async #encrypt(iv: Uint8Array, counterLength: number | undefined, message: Uint8Array): Promise { - const params = cipherParams(this.#name, iv, counterLength); - const sealed = await platformCall(`${this.#name} encrypt`, () => - subtle.encrypt(params, this.#key, asBufferSource(message))); - return new Uint8Array(sealed); - } - - /** - * Decrypt, collapsing EVERY platform failure to one uniform - * `error.other` (reference: webcrypto.js:2871): the WIT closes off the - * padding verdict deliberately. - */ - async #decrypt(iv: Uint8Array, counterLength: number | undefined, message: Uint8Array): Promise { - const params = cipherParams(this.#name, iv, counterLength); - try { - return new Uint8Array(await subtle.decrypt(params, this.#key, asBufferSource(message))); - } catch { - errOther(`${this.#name} decryption failed`); - } - } - - async encrypt( - iv: Uint8Array, - counterLength: number | undefined, - plaintext: Stream, - ): Promise { - const message = await collectByteStream(plaintext); - if (!this.canEncrypt()) notPermitted("encrypt"); - return [await this.#encrypt(iv, counterLength, message)]; - } - - async decrypt( - iv: Uint8Array, - counterLength: number | undefined, - ciphertext: Stream, - ): Promise { - const message = await collectByteStream(ciphertext); - if (!this.canDecrypt()) notPermitted("decrypt"); - return [await this.#decrypt(iv, counterLength, message)]; - } - - async wrap(iv: Uint8Array, counterLength: number | undefined, input: WrapInput): Promise { - const { bytes } = consumeWrapInput(input); - if (!this.canWrap()) notPermitted("wrap"); - return this.#encrypt(iv, counterLength, bytes); - } - - async unwrap(iv: Uint8Array, counterLength: number | undefined, wrapped: Uint8Array): Promise { - if (!this.canUnwrap()) notPermitted("unwrap"); - return new UnwrapInput(await this.#decrypt(iv, counterLength, wrapped)); - } - - algorithmName(): string { - return this.#name; - } - algorithmLength(): number { - return this.#lengthBits; - } - ivSize(): number { - return 16; - } - extractable(): boolean { - return this.#key.extractable; - } - canEncrypt(): boolean { - return this.#grants.encrypt; - } - canDecrypt(): boolean { - return this.#grants.decrypt; - } - canWrap(): boolean { - return this.#grants.wrap; - } - canUnwrap(): boolean { - return this.#grants.unwrap; - } - - exportKeyRaw(): Promise { - return exportRawGated(this.#key); - } - exportKeyJwk(): Promise { - return exportJwkGated(this.#key); - } - async toWrapInputRaw(): Promise { - return new WrapInput("raw", await exportRawGated(this.#key)); - } - async toWrapInputJwk(): Promise { - return new WrapInput("jwk", new TextEncoder().encode(await exportJwkGated(this.#key))); - } -} - -/** The `aes-cbc` / `aes-ctr` minting pair over one mode name (reference: webcrypto.js:2963). */ -function cipherMinting(name: CipherName) { - const minting = { - async importKeyRaw(variant: string, raw: Uint8Array, options: CipherKeyOptions): Promise { - const policy = cipherPolicyOf(options); - const usages = cipherUsages(policy); - const expected = aesVariantByteLength(variant); - if (raw.length !== expected) { - 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); - }, - - async importKeyJwk(variant: string, jwk: string, options: CipherKeyOptions): Promise { - const policy = cipherPolicyOf(options); - const usages = cipherUsages(policy); - const lengthBits = aesVariantByteLength(variant) * 8; - const material = jwkMaterial(jwk); - requireStrictBase64url(material.k); - const key = await importPlatformKeyJwk(`${variant} JWK`, material, { name }, policy.extractable, usages); - const gotBits = jwkKeyBytes(material.k) * 8; - if (gotBits !== lengthBits) { - errInvalidKey(`JWK carries a ${gotBits}-bit key; ${variant} requires ${lengthBits}`); - } - return new CipherKey(key, name, lengthBits, policy); - }, - - async generateKey(variant: string, options: CipherKeyOptions): Promise { - const policy = cipherPolicyOf(options); - const usages = cipherUsages(policy); - 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); - }, - - async deriveKey(variant: string, input: DeriveInput, options: CipherKeyOptions): Promise { - 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); - }, - - unwrapKeyRaw(variant: string, input: UnwrapInput, options: CipherKeyOptions): Promise { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - `unwrapped ${variant} key material`, - () => minting.importKeyRaw(variant, bytes, options), - ); - }, - - unwrapKeyJwk(variant: string, input: UnwrapInput, options: CipherKeyOptions): Promise { - const { bytes } = consumeUnwrapInput(input); - const policy = cipherPolicyOf(options); - cipherUsages(policy); - const jwk = unwrappedJwk(bytes, "enc", cipherGrantedOps(policy)); - return redactingInvalidKey( - `unwrapped ${variant} JWK`, - () => minting.importKeyJwk(variant, jwk, options), - ); - }, - }; - return minting; -} - -/** The `polymorph:webcrypto/cipher@0.1.0` interface: its resource classes. */ -export const cipher = { CipherKey, CipherKeyOptions }; - -/** The `polymorph:webcrypto/aes-cbc@0.1.0` interface. */ -export const aesCbc = cipherMinting("AES-CBC"); - -/** The `polymorph:webcrypto/aes-ctr@0.1.0` interface. */ -export const aesCtr = cipherMinting("AES-CTR"); diff --git a/ports/webcrypto/src/derivation.ts b/ports/webcrypto/src/derivation.ts deleted file mode 100644 index 8a026b9..0000000 --- a/ports/webcrypto/src/derivation.ts +++ /dev/null @@ -1,147 +0,0 @@ -// `polymorph:webcrypto/derivation` — wit/derivation.wit. -// -// `derive-input` is the spec's (base key, normalized params) pair as a -// resource. Sources (`hkdf-sha2.prepare`, `x25519`/`key-agreement.agree`) -// construct it; targets (`aes-gcm.derive-key`, `hmac-sha2.derive-key`, -// `derive-input.derive-bits` itself) consume it. Kept in its own module -// (rather than folded into `hkdf.ts` or `keyAgreement.ts`) because both -// source families mint the same resource type. - -import { errNotPermitted, errOther, notPermitted, platformCall } from "./errors.ts"; - -const subtle = globalThis.crypto.subtle; - -export interface DerivePolicy { - deriveBits: boolean; - deriveKey: boolean; -} - -const derivePolicies = new WeakMap(); - -function derivePolicyOf(o: DeriveOptions): DerivePolicy { - const p = derivePolicies.get(o); - if (p === undefined) errOther("derive-options minted by another provider"); - return p; -} - -/** `derivation.derive-options`: mint-time policy, granting nothing by default (package-wide options contract). */ -export class DeriveOptions { - constructor() { - derivePolicies.set(this, { deriveBits: false, deriveKey: false }); - } - canDeriveBits(allowed: boolean): void { - derivePolicyOf(this).deriveBits = allowed; - } - canDeriveKey(allowed: boolean): void { - derivePolicyOf(this).deriveKey = allowed; - } -} - -/** The policy accumulated on a `derive-options` (read by `hkdf.ts`'s `import-ikm`/`unwrap-ikm`). */ -export function readDerivePolicy(o: DeriveOptions): DerivePolicy { - return { ...derivePolicyOf(o) }; -} - -/** The platform usage pair for a derive policy (WebCrypto's derive-capable-key usages). */ -export function deriveUsages(policy: DerivePolicy): KeyUsage[] { - const usages: KeyUsage[] = []; - if (policy.deriveBits) usages.push("deriveBits"); - if (policy.deriveKey) usages.push("deriveKey"); - if (usages.length === 0) { - errNotPermitted("an options resource granting nothing cannot mint"); - } - return usages; -} - -interface DeriveInputState { - key: CryptoKey; - // deno-lint-ignore no-explicit-any - params: any; - policy: DerivePolicy; - /** Whether `params` denotes an agreement (has a natural output length). */ - hasNaturalLength: boolean; -} - -const inputState = new WeakMap(); - -function inputOf(i: DeriveInput): DeriveInputState { - const s = inputState.get(i); - if (s === undefined) errOther("derive-input minted by another provider"); - return s; -} - -/** Construct a `derive-input` (called only by `hkdf`/`keyAgreement` sources). */ -export function mintDeriveInput( - key: CryptoKey, - // deno-lint-ignore no-explicit-any - params: any, - policy: DerivePolicy, - hasNaturalLength: boolean, -): DeriveInput { - const input = new DeriveInput(); - inputState.set(input, { key, params: { ...params }, policy: { ...policy }, hasNaturalLength }); - return input; -} - -/** `derivation.derive-input`. */ -export class DeriveInput { - canDeriveBits(): boolean { - return inputOf(this).policy.deriveBits; - } - canDeriveKey(): boolean { - return inputOf(this).policy.deriveKey; - } - - async deriveBits(length: number | undefined): Promise { - const state = inputOf(this); - if (!state.policy.deriveBits) notPermitted("derive-bits"); - if (length === undefined) { - if (!state.hasNaturalLength) { - errOther( - "a KDF's output length is a caller choice: it has no natural output length, which only agreement sources define", - ); - } - const secret = await platformCall("agreement derive", () => - subtle.deriveBits(state.params, state.key, null as unknown as number)); - return new Uint8Array(secret); - } - if (length === 0 || length % 8 !== 0) { - errOther(`derive length must be a non-zero multiple of 8 bits, got ${length}`); - } - const bits = await platformCall("KDF derive", () => subtle.deriveBits(state.params, state.key, length)); - return new Uint8Array(bits); - } -} - -/** The internal state a `deriveKeyFrom` target consumes (params + platform key + policy). */ -export function inputStateOf(i: DeriveInput): DeriveInputState { - return inputOf(i); -} - -/** - * Mint a platform key from a `derive-input` (a target interface's - * `derive-key`): requires `can-derive-key`, and an extractable result - * additionally requires `can-derive-bits` — an exportable key is bits - * disclosure by other means (reference: js/jco/webcrypto.js - * `deriveKeyFrom`, lines 1374-1397). - */ -export async function deriveKeyFrom( - input: DeriveInput, - // deno-lint-ignore no-explicit-any - derivedParams: any, - extractable: boolean, - usages: KeyUsage[], -): Promise { - const state = inputOf(input); - if (!state.policy.deriveKey) notPermitted("derive-key"); - if (extractable && !state.policy.deriveBits) { - errNotPermitted( - "minting an extractable key requires the derive-bits grant: an exportable key is bits disclosure by other means", - ); - } - return await platformCall("KDF derive-key", () => - subtle.deriveKey(state.params, state.key, derivedParams, extractable, usages)); -} - -/** The `polymorph:webcrypto/derivation@0.1.0` interface: its resource classes. */ -export const derivation = { DeriveOptions, DeriveInput }; diff --git a/ports/webcrypto/src/digest.ts b/ports/webcrypto/src/digest.ts deleted file mode 100644 index ae501f7..0000000 --- a/ports/webcrypto/src/digest.ts +++ /dev/null @@ -1,65 +0,0 @@ -// `polymorph:webcrypto/digest` (algorithm-agnostic resource) plus -// `polymorph:webcrypto/sha2` (the SHA-2 minting interface) — -// wit/webcrypto.wit `interface digest`, wit/sha2.wit `interface sha2`. -// -// `sha1-checked` (wit/sha1.wit) is `@unstable(feature = sha1-checked)`: -// it requires sha1dc collision detection, which platform WebCrypto does -// not carry (the WIT's own note: "platform-backed providers cannot serve -// this interface"). It IS provided by this port — as a fail-closed -// interface whose two mints refuse with `error.unsupported`; see -// sha1Checked.ts for why providing a refusal beats leaving the leaf -// unlinked. - -import { errUnsupported, platformCall } from "./errors.ts"; -import { asBufferSource, collectByteStream } from "./util.ts"; -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; - -const subtle = globalThis.crypto.subtle; - -/** `digest.digest`: an algorithm-bound, reusable digest capability. */ -export class Digest { - #hashName: string; - constructor(hashName: string) { - this.#hashName = hashName; - } - - async compute(data: Stream): Promise { - const message = await collectByteStream(data); - const out = await platformCall(`${this.#hashName} digest`, () => - subtle.digest(this.#hashName, asBufferSource(message))); - return new Uint8Array(out); - } - - algorithmName(): string { - return this.#hashName; - } -} - -/** - * The served SHA-2 variants (wit/sha2.wit `sha2-variant`): WebCrypto - * serves only SHA-256/384/512, matching the reference's `SHA2_VARIANTS` - * table (js/jco/webcrypto.js:271-276) — the truncated variants - * (sha224, sha512-224, sha512-256) are declined package-wide, per the - * WIT doc, not a Deno-specific gap. - */ -const SHA2_HASH: Readonly> = Object.freeze({ - sha256: "SHA-256", - sha384: "SHA-384", - sha512: "SHA-512", -}); - -function sha2Hash(variant: string): string { - const hash = SHA2_HASH[variant]; - if (hash === undefined) { - errUnsupported(`${variant} is not served by this implementation`); - } - return hash; -} - -/** The `polymorph:webcrypto/sha2@0.1.0` interface. */ -export const sha2 = { - makeDigest: (variant: string): Digest => new Digest(sha2Hash(variant)), -}; - -/** The `polymorph:webcrypto/digest@0.1.0` interface: its resource class. */ -export const digest = { Digest }; diff --git a/ports/webcrypto/src/ec.ts b/ports/webcrypto/src/ec.ts deleted file mode 100644 index b05ce4e..0000000 --- a/ports/webcrypto/src/ec.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Host-side EC public-key admission for the `ecdh` and `ecdsa-*` -// families: the checks the WIT pins that Deno's `crypto.subtle` does not -// perform itself. -// -// Two gaps were measured against the consumer's conformance suites under -// Deno (see the mission report): -// -// 1. Deno admits EC public keys whose point is NOT on the declared curve -// (the Wycheproof `InvalidCurveAttack` family, e.g. -// ecdh_secp256r1_ecpoint_test.json tcId 332-340, all `result: -// "invalid"`). Accepting such a point is the invalid-curve attack -// precondition: a scalar multiplication on a weaker curve leaks the -// private scalar modulo small factors. Node rejects them, the WIT -// requires rejection ("points not on the declared variant's curve fail -// with `error.invalid-key`"), so this module verifies the curve -// equation itself. -// 2. Deno ignores a JWK's `crv` member when it disagrees with the -// requested curve (the suite's `probe/ecdh-key-contract` and -// `probe/ecdh-format-roundtrips` cases), so `crv` — and, for ECDSA, -// the curve-determined `alg` — are checked here. -// -// Both checks are pure predicates over PUBLIC data and strictly monotone: -// they only add rejections in front of the engine, never admit anything -// the engine would refuse. The arithmetic is `BigInt` modular arithmetic -// over the published NIST curve parameters (FIPS 186-4 D.1.2); no secret -// input passes through it. - -import { errInvalidKey } from "./errors.ts"; -import { b64urlDecode, requireStrictBase64url } from "./platform.ts"; - -interface CurveParams { - /** The field prime. */ - p: bigint; - /** The curve's `b` coefficient (`a` is `-3` for both served curves). */ - b: bigint; - /** The coordinate width in bytes. */ - coordBytes: number; -} - -const CURVES: Readonly> = Object.freeze({ - "P-256": { - p: 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n, - b: 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn, - coordBytes: 32, - }, - "P-384": { - p: 2n ** 384n - 2n ** 128n - 2n ** 96n + 2n ** 32n - 1n, - b: 0xb3312fa7e23ee7e4988e056be3f82d19181d9c6efe8141120314088f5013875ac656398d8a2ed19d2a85c8edd3ec2aefn, - coordBytes: 48, - }, -}); - -function beToBigInt(bytes: Uint8Array): bigint { - let value = 0n; - for (const byte of bytes) value = (value << 8n) | BigInt(byte); - return value; -} - -/** Whether `(x, y)` satisfies `y² = x³ − 3x + b (mod p)` with both coordinates in the field. */ -function isOnCurve(curve: CurveParams, x: bigint, y: bigint): boolean { - const { p, b } = curve; - if (x < 0n || x >= p || y < 0n || y >= p) return false; - const lhs = (y * y) % p; - const rhs = (((x * x % p) * x) % p - 3n * x % p + b) % p; - return lhs === ((rhs % p) + p) % p; -} - -/** Reject an uncompressed SEC1 point (`04 ‖ x ‖ y`) that is not on `namedCurve`. */ -export function requireOnCurveSec1(namedCurve: string, point: Uint8Array): void { - const curve = CURVES[namedCurve]; - if (curve === undefined) return; // an unserved curve never reaches an import - const expected = 1 + 2 * curve.coordBytes; - if (point.length !== expected || point[0] !== 0x04) { - errInvalidKey(`${namedCurve} public keys are uncompressed SEC1 points (${expected} bytes, leading 0x04)`); - } - const x = beToBigInt(point.subarray(1, 1 + curve.coordBytes)); - const y = beToBigInt(point.subarray(1 + curve.coordBytes)); - if (!isOnCurve(curve, x, y)) { - errInvalidKey(`${namedCurve} public key is not a point on the declared curve`); - } -} - -/** - * Reject a SubjectPublicKeyInfo whose embedded point is not on - * `namedCurve`. The AlgorithmIdentifier was already pinned to the - * named-curve OID form (`requireNamedCurveSpki`), and in that form the - * subjectPublicKey BIT STRING's contents are exactly the SEC1 point, so - * the point is the DER's trailing `1 + 2·coordBytes` bytes. An input whose - * tail does not have that shape is left to the platform's full DER - * validation (this guard only ever adds rejections). - */ -export function requireOnCurveSpki(namedCurve: string, spki: Uint8Array): void { - const curve = CURVES[namedCurve]; - if (curve === undefined) return; - const uncompressed = 1 + 2 * curve.coordBytes; - if (spki.length >= uncompressed) { - const point = spki.subarray(spki.length - uncompressed); - if (point[0] === 0x04) { - requireOnCurveSec1(namedCurve, point); - return; - } - } - // A COMPRESSED encoding. The WIT leaves acceptance implementation- - // defined ("do not rely on either behavior"), and engines split: Node - // refuses, Deno decompresses — including, as the Wycheproof - // `CompressedPoint`/`WrongCurve` cases show (ecdh_secp256r1_test.json - // tcId 384-390), onto low-order points of the curve's twist, which is - // the invalid-curve precondition again. This port takes the - // conservative branch of the latitude and refuses compressed SPKI - // points outright. - const compressed = 1 + curve.coordBytes; - if (spki.length >= compressed) { - const prefix = spki[spki.length - compressed]; - if (prefix === 0x02 || prefix === 0x03) { - errInvalidKey(`${namedCurve} SPKI carries a compressed point, which this implementation does not admit`); - } - } -} - -/** Reject an EC JWK whose `crv` disagrees with the declared variant's curve, or whose point is off-curve. */ -export function requireEcJwkCurve(namedCurve: string, jwk: Record): void { - if (jwk.crv !== namedCurve) { - errInvalidKey(`EC JWK declares crv ${String(jwk.crv)}; the requested curve is ${namedCurve}`); - } - const curve = CURVES[namedCurve]; - if (curve === undefined) return; - const { x, y } = jwk; - if (typeof x !== "string" || typeof y !== "string") return; // the platform reports the shape error - requireStrictBase64url(x); - requireStrictBase64url(y); - const xb = b64urlDecode(x); - const yb = b64urlDecode(y); - if (xb.length !== curve.coordBytes || yb.length !== curve.coordBytes) { - errInvalidKey(`EC JWK coordinates must be ${curve.coordBytes} bytes for ${namedCurve}`); - } - if (!isOnCurve(curve, beToBigInt(xb), beToBigInt(yb))) { - errInvalidKey(`EC JWK is not a point on ${namedCurve}`); - } -} - -/** The curve-determined JOSE signature alg an ECDSA JWK's `alg` must name exactly, when present. */ -const ECDSA_JOSE_ALG: Readonly> = Object.freeze({ - "P-256": "ES256", - "P-384": "ES384", -}); - -/** Reject an ECDSA JWK whose `alg` is not the curve's JOSE alg (case-exact — the WIT pins the spelling). */ -export function requireEcdsaJwkAlg(namedCurve: string, jwk: Record): void { - const alg = jwk.alg; - if (alg === undefined) return; - const expected = ECDSA_JOSE_ALG[namedCurve]; - if (expected !== undefined && alg !== expected) { - errInvalidKey(`EC JWK declares alg ${String(alg)}; ${namedCurve} signature keys use ${expected}`); - } -} diff --git a/ports/webcrypto/src/ecdh.ts b/ports/webcrypto/src/ecdh.ts deleted file mode 100644 index 34adf62..0000000 --- a/ports/webcrypto/src/ecdh.ts +++ /dev/null @@ -1,172 +0,0 @@ -// `polymorph:webcrypto/ecdh` — wit/ecdh.wit. The resources are -// `key-agreement`'s (this interface only mints them), so this module is -// pure minting: the curve table, the raw/SPKI/JWK admission rules the WIT -// pins ahead of the platform, and the unwrap mints. -// -// Behavioral reference: js/jco/webcrypto.js:2060-2390. - -import { errInvalidKey, platformCall } from "./errors.ts"; -import { - importPlatformKey, - importPlatformKeyJwk, - jwkMaterial, - redactingInvalidKey, - requireNamedCurveSpki, - requireStrictBase64url, - served, -} from "./platform.ts"; -import { - AGREEMENT_PLATFORM_USAGES, - agreementGrantedOps, - type AgreementKeyOptions, - agreementPolicyOf, - PublicKey, - requireAgreementGrant, - SecretKey, -} from "./keyAgreement.ts"; -import { requireEcJwkCurve, requireOnCurveSec1, requireOnCurveSpki } from "./ec.ts"; -import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; -import { unwrappedJwk } from "./util.ts"; - -const subtle = globalThis.crypto.subtle; - -/** - * The served `ecdh-variant` entries (reference: webcrypto.js:2064). `p521` - * is declared by the WIT and served by no implementation of this package — - * `served` renders the decline as `error.unsupported`. - */ -const ECDH_CURVES: Readonly> = Object.freeze({ - p256: { namedCurve: "P-256", publicLength: 65 }, - p384: { namedCurve: "P-384", publicLength: 97 }, -}); - -function ecdhCurve(variant: string): { namedCurve: string; publicLength: number } { - return served(ECDH_CURVES, variant); -} - -/** The `polymorph:webcrypto/ecdh@0.1.0` interface. */ -export const ecdh = { - /** - * Uncompressed SEC1 only: the length and leading-`0x04` checks are - * enforced here because engines differ on compressed-point raw imports - * and the WIT pins their rejection (reference: webcrypto.js:2094). - */ - importPublicKeyRaw: async (variant: string, raw: Uint8Array): Promise => { - const entry = ecdhCurve(variant); - if (raw.length !== entry.publicLength || raw[0] !== 0x04) { - errInvalidKey( - `${variant} public keys are uncompressed SEC1 points (${entry.publicLength} bytes, leading 0x04)`, - ); - } - requireOnCurveSec1(entry.namedCurve, raw); - const key = await importPlatformKey( - `${variant} public key`, - "raw", - raw, - { name: "ECDH", namedCurve: entry.namedCurve }, - true, - [], - ); - return new PublicKey(key); - }, - - importPublicKeySpki: async (variant: string, spki: Uint8Array): Promise => { - const entry = ecdhCurve(variant); - requireNamedCurveSpki(entry.namedCurve, spki); - requireOnCurveSpki(entry.namedCurve, spki); - const key = await importPlatformKey( - `${variant} spki`, - "spki", - spki, - { name: "ECDH", namedCurve: entry.namedCurve }, - true, - [], - ); - return new PublicKey(key); - }, - - importPublicKeyJwk: async (variant: string, jwkText: string): Promise => { - const entry = ecdhCurve(variant); - const jwk = jwkMaterial(jwkText); - requireEcJwkCurve(entry.namedCurve, jwk); - const key = await importPlatformKeyJwk( - `${variant} public JWK`, - jwk, - { name: "ECDH", namedCurve: entry.namedCurve }, - true, - [], - ); - return new PublicKey(key); - }, - - importSecretKeyJwk: async (variant: string, jwkText: string, options: AgreementKeyOptions): Promise => { - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const entry = ecdhCurve(variant); - const jwk = jwkMaterial(jwkText); - requireEcJwkCurve(entry.namedCurve, jwk); - requireStrictBase64url(jwk.d); - const key = await importPlatformKeyJwk( - `${variant} private JWK`, - jwk, - { name: "ECDH", namedCurve: entry.namedCurve }, - policy.extractable, - AGREEMENT_PLATFORM_USAGES, - ); - if (key.type !== "private") { - errInvalidKey("EC private JWK must carry `d` (base64url private scalar)"); - } - return new SecretKey(key, policy); - }, - - importSecretKeyPkcs8: async ( - variant: string, - pkcs8: Uint8Array, - options: AgreementKeyOptions, - ): Promise => { - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const entry = ecdhCurve(variant); - const key = await importPlatformKey( - `${variant} pkcs8`, - "pkcs8", - pkcs8, - { name: "ECDH", namedCurve: entry.namedCurve }, - policy.extractable, - AGREEMENT_PLATFORM_USAGES, - ); - return new SecretKey(key, policy); - }, - - generateKey: async (variant: string, options: AgreementKeyOptions): Promise<[SecretKey, PublicKey]> => { - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const entry = ecdhCurve(variant); - const pair = await platformCall(`${variant} key generation`, () => - subtle.generateKey( - { name: "ECDH", namedCurve: entry.namedCurve }, - policy.extractable, - AGREEMENT_PLATFORM_USAGES, - )) as CryptoKeyPair; - return [new SecretKey(pair.privateKey, policy), new PublicKey(pair.publicKey)]; - }, - - unwrapSecretKeyJwk: (variant: string, input: UnwrapInput, options: AgreementKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const jwk = unwrappedJwk(bytes, "enc", agreementGrantedOps(policy)); - return redactingInvalidKey( - `unwrapped ${variant} private JWK`, - () => ecdh.importSecretKeyJwk(variant, jwk, options), - ); - }, - - unwrapSecretKeyPkcs8: (variant: string, input: UnwrapInput, options: AgreementKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - `unwrapped ${variant} pkcs8`, - () => ecdh.importSecretKeyPkcs8(variant, bytes, options), - ); - }, -}; diff --git a/ports/webcrypto/src/ecdsa.ts b/ports/webcrypto/src/ecdsa.ts deleted file mode 100644 index 8b3732d..0000000 --- a/ports/webcrypto/src/ecdsa.ts +++ /dev/null @@ -1,180 +0,0 @@ -// `polymorph:webcrypto/ecdsa-verify` + `ecdsa-sign` — wit/ecdsa.wit. The -// resources are `signature`'s; this module supplies the variant table and -// the minting paths. -// -// Behavioral reference: js/jco/webcrypto.js:3970-4035 (the variant table), -// 4696-4930 (the minting paths). Signatures are P1363 (`r ‖ s`), whose -// fixed width `VerifyingKey.verify` enforces from the record below. - -import { errInvalidKey, platformCall } from "./errors.ts"; -import { - importPlatformKey, - importPlatformKeyJwk, - jwkMaterial, - redactingInvalidKey, - requireNamedCurveSpki, - requireStrictBase64url, - served, -} from "./platform.ts"; -import { - requireSigningGrant, - type SignatureAlgorithm, - SigningKey, - type SigningKeyOptions, - signingPolicyOf, - VerifyingKey, -} from "./signature.ts"; -import { requireEcdsaJwkAlg, requireEcJwkCurve, requireOnCurveSec1, requireOnCurveSpki } from "./ec.ts"; -import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; -import { unwrappedJwk } from "./util.ts"; - -const subtle = globalThis.crypto.subtle; - -/** The served `ecdsa-variant` entries (reference: webcrypto.js:3975). `p521-sha512` is declared and unserved. */ -const ECDSA_VARIANTS: Readonly> = Object.freeze({ - "p256-sha256": p(256, "SHA-256"), - "p256-sha384": p(256, "SHA-384"), - "p256-sha512": p(256, "SHA-512"), - "p384-sha256": p(384, "SHA-256"), - "p384-sha384": p(384, "SHA-384"), - "p384-sha512": p(384, "SHA-512"), -}); - -function p(curveBits: 256 | 384, hash: string): SignatureAlgorithm { - const scalarLength = curveBits === 256 ? 32 : 48; - return Object.freeze({ - name: "ECDSA", - namedCurve: `P-${curveBits}`, - hash, - publicLength: scalarLength * 2 + 1, - signatureLength: scalarLength * 2, - }); -} - -function ecdsaVariant(variant: string): SignatureAlgorithm { - return served(ECDSA_VARIANTS, variant); -} - -/** The `polymorph:webcrypto/ecdsa-verify@0.1.0` interface. */ -export const ecdsaVerify = { - importVerifyingKeyRaw: async (variant: string, raw: Uint8Array): Promise => { - const entry = ecdsaVariant(variant); - if (raw.length !== entry.publicLength || raw[0] !== 0x04) { - errInvalidKey( - `${variant} public keys are uncompressed SEC1 points (${entry.publicLength} bytes, leading 0x04)`, - ); - } - requireOnCurveSec1(entry.namedCurve as string, raw); - const key = await importPlatformKey( - `${variant} public key`, - "raw", - raw, - { name: "ECDSA", namedCurve: entry.namedCurve }, - true, - ["verify"], - ); - return new VerifyingKey(key, entry); - }, - - importVerifyingKeySpki: async (variant: string, spki: Uint8Array): Promise => { - const entry = ecdsaVariant(variant); - requireNamedCurveSpki(entry.namedCurve as string, spki); - requireOnCurveSpki(entry.namedCurve as string, spki); - const key = await importPlatformKey( - `${variant} spki`, - "spki", - spki, - { name: "ECDSA", namedCurve: entry.namedCurve }, - true, - ["verify"], - ); - return new VerifyingKey(key, entry); - }, - - importVerifyingKeyJwk: async (variant: string, jwkText: string): Promise => { - const entry = ecdsaVariant(variant); - const jwk = jwkMaterial(jwkText); - requireEcJwkCurve(entry.namedCurve as string, jwk); - requireEcdsaJwkAlg(entry.namedCurve as string, jwk); - const key = await importPlatformKeyJwk( - `${variant} public JWK`, - jwk, - { name: "ECDSA", namedCurve: entry.namedCurve }, - true, - ["verify"], - ); - return new VerifyingKey(key, entry); - }, -}; - -/** The `polymorph:webcrypto/ecdsa-sign@0.1.0` interface. */ -export const ecdsaSign = { - generateKey: async (variant: string, options: SigningKeyOptions): Promise<[SigningKey, VerifyingKey]> => { - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const entry = ecdsaVariant(variant); - const pair = await platformCall(`${variant} key generation`, () => - subtle.generateKey( - { name: "ECDSA", namedCurve: entry.namedCurve as string }, - policy.extractable, - ["sign", "verify"], - )) as CryptoKeyPair; - return [new SigningKey(pair.privateKey, entry), new VerifyingKey(pair.publicKey, entry)]; - }, - - importSigningKeyPkcs8: async ( - variant: string, - pkcs8: Uint8Array, - options: SigningKeyOptions, - ): Promise => { - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const entry = ecdsaVariant(variant); - const key = await importPlatformKey( - `${variant} pkcs8`, - "pkcs8", - pkcs8, - { name: "ECDSA", namedCurve: entry.namedCurve }, - policy.extractable, - ["sign"], - ); - return new SigningKey(key, entry); - }, - - importSigningKeyJwk: async (variant: string, jwkText: string, options: SigningKeyOptions): Promise => { - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const entry = ecdsaVariant(variant); - const jwk = jwkMaterial(jwkText); - requireEcJwkCurve(entry.namedCurve as string, jwk); - requireEcdsaJwkAlg(entry.namedCurve as string, jwk); - requireStrictBase64url(jwk.d); - const key = await importPlatformKeyJwk( - `${variant} private JWK`, - jwk, - { name: "ECDSA", namedCurve: entry.namedCurve }, - policy.extractable, - ["sign"], - ); - if (key.type !== "private") errInvalidKey("EC private JWK must carry `d` (base64url private scalar)"); - return new SigningKey(key, entry); - }, - - unwrapSigningKeyPkcs8: (variant: string, input: UnwrapInput, options: SigningKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - `unwrapped ${variant} pkcs8`, - () => ecdsaSign.importSigningKeyPkcs8(variant, bytes, options), - ); - }, - - unwrapSigningKeyJwk: (variant: string, input: UnwrapInput, options: SigningKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - requireSigningGrant(signingPolicyOf(options)); - const jwk = unwrappedJwk(bytes, "sig", ["sign"]); - return redactingInvalidKey( - `unwrapped ${variant} private JWK`, - () => ecdsaSign.importSigningKeyJwk(variant, jwk, options), - ); - }, -}; diff --git a/ports/webcrypto/src/errors.ts b/ports/webcrypto/src/errors.ts deleted file mode 100644 index 7b94862..0000000 --- a/ports/webcrypto/src/errors.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Shared error taxonomy plumbing for the `polymorph:webcrypto` port. -// -// Governing docs: -// - contracts/embedder-api.md §"Error model" — host imports report a WIT -// `result<_, error>` err case by throwing `new ComponentException(payload)`; an -// UNBRANDED throw becomes a host-fatal trap. That is a deliberate -// inversion of jco's convention (any stray `TypeError` was fed to the -// lift), which is why the polymorph reference wraps every platform call -// in `platformCall` (js/jco/webcrypto.js:234-262): under OUR conventions -// that wrapper is unnecessary by construction, so this module keeps only -// the DOMException -> WIT-taxonomy MAPPING, not the defensive scaffold. -// - wit/webcrypto.wit `interface types` — the closed `error` variant this -// mapping targets. -// -// `error` is a WIT variant; per the value-mapping table -// (contracts/embedder-api.md §"Value mapping", `variant` row) its payload -// shape is `{ kind, value? }` with `value` absent for payloadless cases. - -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -/** The `types.error` payload shape (the value-mapping table's variant row). */ -export type WcErrorPayload = - | { kind: "invalid-key"; value: string } - | { kind: "invalid-nonce"; value: string } - | { kind: "authentication-failed" } - | { kind: "not-extractable" } - | { kind: "unsupported"; value: string } - | { kind: "not-permitted"; value: string } - | { kind: "other"; value: string } - | { kind: "extension"; value: { origin: string; name: string; message: string } }; - -/** Throw the branded `result<_, error>` err value for a WIT-declared case. */ -export function witError(payload: WcErrorPayload): never { - throw new ComponentException(payload); -} - -export function errInvalidKey(detail: string): never { - return witError({ kind: "invalid-key", value: detail }); -} -export function errInvalidNonce(detail: string): never { - return witError({ kind: "invalid-nonce", value: detail }); -} -export function errAuthenticationFailed(): never { - return witError({ kind: "authentication-failed" }); -} -export function errNotExtractable(): never { - return witError({ kind: "not-extractable" }); -} -export function errUnsupported(detail: string): never { - return witError({ kind: "unsupported", value: detail }); -} -export function errNotPermitted(detail: string): never { - return witError({ kind: "not-permitted", value: detail }); -} -export function errOther(detail: string): never { - return witError({ kind: "other", value: detail }); -} - -/** The refusal an operation renders on a usage-denied key (reference parity: js/jco/webcrypto.js:162-164). */ -export function notPermitted(operation: string): never { - return errNotPermitted(`this key does not permit ${operation}`); -} - -/** The name/message pair of a caught platform (DOMException-shaped) rejection. */ -function asPlatformError(err: unknown): { name: string | undefined; detail: string } { - const shape = err as { name?: unknown; message?: unknown } | null | undefined; - const name = typeof shape?.name === "string" ? shape.name : undefined; - const message = typeof shape?.message === "string" ? shape.message : undefined; - return { name, detail: message ?? String(err) }; -} - -/** - * Await a `crypto.subtle` call, mapping a `DOMException` onto the WIT error - * taxonomy (the reference's `platformCall` DOMException mapping, ported; - * js/jco/webcrypto.js:251-262). `NotSupportedError` is the WIT's - * "well-formed request this implementation does not serve" - * (`error.unsupported`); everything else platform-thrown is operational - * (`error.other`). Anything already a `ComponentException` passes through unchanged. - * An exception that is neither a `ComponentException` nor DOMException-shaped is a - * host bug, not a taxonomy case: it is rethrown as-is and becomes a trap - * per contracts/embedder-api.md's error model, not smuggled into `other`. - */ -export async function platformCall(what: string, run: () => Promise): Promise { - try { - return await run(); - } catch (err) { - if (err instanceof ComponentException) throw err; - const { name, detail } = asPlatformError(err); - if (err instanceof DOMException) { - if (name === "NotSupportedError") { - errUnsupported(`${what} is not served by this platform: ${detail}`); - } - errOther(`${what} failed: ${detail}`); - } - // CONTRACT: contracts/embedder-api.md's error model makes an unbranded - // throw a host-fatal trap, which is right for a bug in THIS port — but - // the throw here came out of `crypto.subtle`, not out of port logic. - // Deno's WebCrypto reports several capability limits as a plain - // `TypeError`/`Error` rather than a `DOMException` (an AES-CTR counter - // width it does not serve; an RSA modulus size its key handling - // rejects). Trapping the component for a platform limitation would - // destroy the guest's ability to observe a refusal it is entitled to - // handle, so a platform-originated `TypeError` is rendered as the - // WIT's "well-formed request this implementation does not serve" - // (`unsupported`) and any other platform-originated error as the - // operational `other`. Nothing outside a `crypto.subtle` call reaches - // this arm: `platformCall` wraps platform calls only. - if (err instanceof TypeError) { - errUnsupported(`${what} is not served by this platform: ${detail}`); - } - if (err instanceof Error) { - errOther(`${what} failed: ${detail}`); - } - throw err; - } -} - -/** - * Lift a `subtle.decrypt`/`unwrapKey`-style rejection (reference: - * js/jco/webcrypto.js `decryptFailure`, lines 182-196). A failed AEAD tag - * check surfaces as `OperationError`, which is `authentication-failed` and - * deliberately detail-free (WIT contract: a failed verification MUST report - * this case and nothing else). Any other DOMException is an operational - * condition, not a security verdict, so it stays `other` with detail — - * conflating the two would render a local fault as an attack signal. - */ -export function decryptFailure(err: unknown, operation = "open"): never { - if (err instanceof DOMException && err.name === "OperationError") { - errAuthenticationFailed(); - } - const { detail } = asPlatformError(err); - errOther(`${operation}: ${detail}`); -} - -/** The WebCrypto usages granted by `pairs`, throwing `not-permitted` on an all-false grant (package-wide options contract: mint requires at least one usage). */ -export function grantedUsages(pairs: Array<[KeyUsage, boolean]>): KeyUsage[] { - const usages = pairs.filter(([, granted]) => granted).map(([usage]) => usage); - if (usages.length === 0) { - errNotPermitted("a key with no enabled usage cannot be minted"); - } - return usages; -} - -/** The `{name, message}` pair of a caught platform rejection, for the callers that branch on `DOMException.name` (reference: js/jco/webcrypto.js:227). */ -export function asPlatformFailure(err: unknown): { name: string | undefined; detail: string } { - return asPlatformError(err); -} diff --git a/ports/webcrypto/src/hkdf.ts b/ports/webcrypto/src/hkdf.ts deleted file mode 100644 index 8e4aaf3..0000000 --- a/ports/webcrypto/src/hkdf.ts +++ /dev/null @@ -1,124 +0,0 @@ -// `polymorph:webcrypto/hkdf` + `hkdf-sha2` + `hkdf-sha1` — wit/hkdf.wit. - -import { errOther, errUnsupported, notPermitted, platformCall } from "./errors.ts"; -import { - DeriveInput, - type DerivePolicy, - deriveUsages, - inputStateOf, - mintDeriveInput, - readDerivePolicy, -} from "./derivation.ts"; -import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; -import { asBufferSource } from "./util.ts"; -import type { DeriveOptions } from "./derivation.ts"; - -const subtle = globalThis.crypto.subtle; - -const ikmState = new WeakMap(); - -/** `hkdf.ikm`: input keying material, consumable only by `prepare`. */ -export class Ikm { - canDeriveBits(): boolean { - return ikmState.get(this)!.policy.deriveBits; - } - canDeriveKey(): boolean { - return ikmState.get(this)!.policy.deriveKey; - } -} - -function mintIkm(key: CryptoKey, policy: DerivePolicy): Ikm { - const ikm = new Ikm(); - ikmState.set(ikm, { key, policy: { ...policy } }); - return ikm; -} - -async function importIkmKey(raw: Uint8Array, options: DeriveOptions): Promise { - const policy = readPolicy(options); - const usages = deriveUsages(policy); - const key = await platformCall("HKDF import ikm", () => - subtle.importKey("raw", asBufferSource(raw), "HKDF", false, usages)); - return mintIkm(key, policy); -} - -// `DeriveOptions` exposes only mint-time setters per the WIT; `hkdf`'s -// `import-ikm`/`unwrap-ikm` need the accumulated policy to mint an `ikm`, -// so `derivation.ts` exports a read accessor rather than this module -// re-deriving it by hand. -function readPolicy(options: DeriveOptions): DerivePolicy { - return readDerivePolicy(options); -} - -/** The `polymorph:webcrypto/hkdf@0.1.0` interface. */ -export const hkdf = { - Ikm, - importIkm: (raw: Uint8Array, options: DeriveOptions) => importIkmKey(raw, options), - unwrapIkm: (input: UnwrapInput, options: DeriveOptions) => { - const { bytes } = consumeUnwrapInput(input); - return importIkmKey(bytes, options); - }, -}; - -const SHA2_HASH: Readonly> = Object.freeze({ - sha256: "SHA-256", - sha384: "SHA-384", - sha512: "SHA-512", -}); - -function sha2Hash(variant: string): string { - const hash = SHA2_HASH[variant]; - if (hash === undefined) errUnsupported(`${variant} is not served by this implementation`); - return hash; -} - -async function prepare(hash: string, input: Ikm, salt: Uint8Array, info: Uint8Array): Promise { - const state = ikmState.get(input); - if (state === undefined) { - errOther("ikm minted by another provider"); - } - const params = { - name: "HKDF", - hash, - salt: asBufferSource(salt.slice()), - info: asBufferSource(info.slice()), - }; - return mintDeriveInput(state.key, params, state.policy, /* hasNaturalLength */ false); -} - -async function prepareFrom( - hash: string, - input: DeriveInput, - salt: Uint8Array, - info: Uint8Array, -): Promise { - const upstream = inputStateOf(input); - if (!upstream.policy.deriveKey) notPermitted("derive-key"); - if (!upstream.hasNaturalLength) { - errOther( - "chaining from another KDF's input is not served: a KDF's output length is a caller choice, not natural", - ); - } - const secret = await subtle.deriveBits(upstream.params, upstream.key, null as unknown as number); - const key = await subtle.importKey("raw", secret, "HKDF", false, deriveUsages(upstream.policy)); - const params = { - name: "HKDF", - hash, - salt: asBufferSource(salt.slice()), - info: asBufferSource(info.slice()), - }; - return mintDeriveInput(key, params, upstream.policy, false); -} - -/** The `polymorph:webcrypto/hkdf-sha2@0.1.0` interface. */ -export const hkdfSha2 = { - prepare: (variant: string, input: Ikm, salt: Uint8Array, info: Uint8Array) => - prepare(sha2Hash(variant), input, salt, info), - prepareFrom: (variant: string, input: DeriveInput, salt: Uint8Array, info: Uint8Array) => - prepareFrom(sha2Hash(variant), input, salt, info), -}; - -/** The `polymorph:webcrypto/hkdf-sha1@0.1.0` interface. */ -export const hkdfSha1 = { - prepare: (input: Ikm, salt: Uint8Array, info: Uint8Array) => prepare("SHA-1", input, salt, info), - prepareFrom: (input: DeriveInput, salt: Uint8Array, info: Uint8Array) => prepareFrom("SHA-1", input, salt, info), -}; diff --git a/ports/webcrypto/src/keyAgreement.ts b/ports/webcrypto/src/keyAgreement.ts deleted file mode 100644 index d2eb3b6..0000000 --- a/ports/webcrypto/src/keyAgreement.ts +++ /dev/null @@ -1,257 +0,0 @@ -// `polymorph:webcrypto/key-agreement` (algorithm-agnostic agreement -// resources) plus `x25519` (RFC 7748) — wit/agreement.wit, wit/x25519.wit. -// -// The iroh identity/exec-model path (mission context) exercises this family -// directly: tools/smoke-c0/leg2_exec_model.ts and -// wasi-shims/tests/integration_exec_model_test.ts's `webcryptoFixture()` -// both hand-roll exactly `key-agreement.{AgreementKeyOptions,PublicKey, -// SecretKey}` + `x25519.generateKey`; this module is the real port of that -// fixture, extended to the WIT's full import/unwrap/agree surface. - -import { asPlatformFailure, errInvalidKey, errNotExtractable, errNotPermitted, errOther, platformCall } from "./errors.ts"; -import { importPlatformKey, importPlatformKeyJwk, jwkMaterial, redactingInvalidKey, requireStrictBase64url } from "./platform.ts"; -import { type DeriveInput, mintDeriveInput } from "./derivation.ts"; -import { consumeUnwrapInput, type UnwrapInput, WrapInput } from "./wrapping.ts"; -import { unwrappedJwk } from "./util.ts"; - -const subtle = globalThis.crypto.subtle; - -export interface AgreementPolicy { - deriveBits: boolean; - deriveKey: boolean; - extractable: boolean; -} - -const optionsState = new WeakMap(); - -export function agreementPolicyOf(o: AgreementKeyOptions): AgreementPolicy { - const p = optionsState.get(o); - if (p === undefined) errOther("agreement-key-options minted by another provider"); - return p; -} - -/** `key-agreement.agreement-key-options`. */ -export class AgreementKeyOptions { - constructor() { - optionsState.set(this, { deriveBits: false, deriveKey: false, extractable: false }); - } - canDeriveBits(allowed: boolean): void { - agreementPolicyOf(this).deriveBits = allowed; - } - canDeriveKey(allowed: boolean): void { - agreementPolicyOf(this).deriveKey = allowed; - } - extractable(allowed: boolean): void { - agreementPolicyOf(this).extractable = allowed; - } -} - -/** `key-agreement.public-key`: exchangeable, secret-free. */ -export class PublicKey { - #key: CryptoKey; - constructor(key: CryptoKey) { - this.#key = key; - } - get cryptoKey(): CryptoKey { - return this.#key; - } - algorithmName(): string { - return this.#key.algorithm.name; - } - async exportKeyRaw(): Promise { - const raw = await platformCall("export raw", () => subtle.exportKey("raw", this.#key)); - return new Uint8Array(raw); - } - async exportKeyJwk(): Promise { - const jwk = await platformCall("export jwk", () => subtle.exportKey("jwk", this.#key)); - // Material members only, per the package-wide JWK contract (reference: - // js/jco/webcrypto.js:1729-1741): OKP for X25519, EC for ECDH. - return JSON.stringify( - jwk.kty === "OKP" - ? { kty: jwk.kty, crv: jwk.crv, x: jwk.x } - : { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, - ); - } - async exportKeySpki(): Promise { - const spki = await platformCall("export spki", () => subtle.exportKey("spki", this.#key)); - return new Uint8Array(spki); - } -} - -/** `key-agreement.secret-key`. */ -export class SecretKey { - #key: CryptoKey; - #policy: AgreementPolicy; - constructor(key: CryptoKey, policy: AgreementPolicy) { - this.#key = key; - this.#policy = { ...policy }; - } - - /** - * The shared secret with `peer` as a `derive-input` with a *natural* - * output length (the whole agreed secret — 32 bytes for X25519), per - * wit/agreement.wit `secret-key.agree`. The params bound here - * (`{name:"X25519", public: peer}`) drive `derivation.ts`'s shared - * `deriveBits`/`deriveKeyFrom` machinery directly against this secret - * key — no intermediate re-import, matching WebCrypto's own - * `deriveBits`/`deriveKey` over an ECDH-family algorithm. - * - * `error.invalid-key` on the platform's mandatory contributory - * (all-zero shared-secret) check surfaces from `derive-input.derive-bits` - * itself (WebCrypto rejects a small-order peer there), not here. - */ - async agree(peer: PublicKey): Promise { - const params = { name: this.#key.algorithm.name, public: peer.cryptoKey } as unknown as Record; - // The WIT pins the contributory (all-zero shared secret) check HERE, - // so the platform derivation runs once now as a probe and its output - // is discarded (reference: js/jco/webcrypto.js:1795-1815). An - // algorithm-mismatched peer surfaces from the same probe. - try { - await subtle.deriveBits(params as unknown as AlgorithmIdentifier, this.#key, null as unknown as number); - } catch (err) { - const failure = asPlatformFailure(err); - if (failure.name === "OperationError") { - errInvalidKey("the shared secret is all-zero: the peer public key is a small-order point"); - } - if (failure.name === "InvalidAccessError") { - errInvalidKey(`peer key is not usable with this key: ${failure.detail}`); - } - errOther(`agreement failed: ${failure.detail}`); - } - return mintDeriveInput(this.#key, params, this.#policy, /* hasNaturalLength */ true); - } - - algorithmName(): string { - return this.#key.algorithm.name; - } - canDeriveBits(): boolean { - return this.#policy.deriveBits; - } - canDeriveKey(): boolean { - return this.#policy.deriveKey; - } - extractable(): boolean { - return this.#policy.extractable; - } - async exportKeyJwk(): Promise { - if (!this.#policy.extractable) errNotExtractable(); - const jwk = await platformCall("export jwk", () => subtle.exportKey("jwk", this.#key)); - // Material members only (reference: js/jco/webcrypto.js:1835-1846). - return JSON.stringify( - jwk.kty === "OKP" - ? { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d } - : { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d }, - ); - } - async exportKeyPkcs8(): Promise { - if (!this.#policy.extractable) errNotExtractable(); - const pkcs8 = await platformCall("export pkcs8", () => subtle.exportKey("pkcs8", this.#key)); - return new Uint8Array(pkcs8); - } - async toWrapInputJwk(): Promise { - const jwk = await this.exportKeyJwk(); - return new WrapInput("jwk", new TextEncoder().encode(jwk)); - } - async toWrapInputPkcs8(): Promise { - return new WrapInput("pkcs8", await this.exportKeyPkcs8()); - } -} - -/** The `polymorph:webcrypto/key-agreement@0.1.0` interface: its resource classes. */ -export const keyAgreement = { AgreementKeyOptions, PublicKey, SecretKey }; - -/** - * The usages every platform agreement secret key is minted with - * (reference: js/jco/webcrypto.js:1765-1770): unlike the KDF base secrets, - * the WIT grants do NOT ride the platform usages — `agree`'s contributory - * probe is a platform `deriveBits` call and chaining is a `deriveKey` - * call, and either must work whichever single grant the mint carried. The - * grants are enforced host-side by `derive-input` instead. - */ -export const AGREEMENT_PLATFORM_USAGES: KeyUsage[] = ["deriveBits", "deriveKey"]; - -/** At least one derive grant, without projecting onto platform usages (reference: webcrypto.js:1888). */ -export function requireAgreementGrant(policy: AgreementPolicy): void { - if (!policy.deriveBits && !policy.deriveKey) { - errNotPermitted("a key with no enabled usage cannot be minted"); - } -} - -/** The granted operations' platform names, for the unwrap-path `key_ops` rule (reference: webcrypto.js:2008). */ -export function agreementGrantedOps(policy: AgreementPolicy): string[] { - const ops: string[] = []; - if (policy.deriveBits) ops.push("deriveBits"); - if (policy.deriveKey) ops.push("deriveKey"); - return ops; -} - -/** The `polymorph:webcrypto/x25519@0.1.0` interface. */ -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); - }, - importPublicKeySpki: async (spki: Uint8Array): Promise => { - const key = await importPlatformKey("X25519 spki", "spki", spki, "X25519", true, []); - return new PublicKey(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); - }, - importSecretKeyJwk: async (jwkText: string, options: AgreementKeyOptions): Promise => { - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const jwk = jwkMaterial(jwkText); - requireStrictBase64url(jwk.x); - requireStrictBase64url(jwk.d); - const key = await importPlatformKeyJwk( - "X25519 private JWK", - jwk, - "X25519", - policy.extractable, - AGREEMENT_PLATFORM_USAGES, - ); - if (key.type !== "private") { - errInvalidKey("OKP private JWK must carry `d` (base64url private key)"); - } - return new SecretKey(key, policy); - }, - importSecretKeyPkcs8: async (pkcs8: Uint8Array, options: AgreementKeyOptions): Promise => { - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const key = await importPlatformKey( - "X25519 pkcs8", - "pkcs8", - pkcs8, - "X25519", - policy.extractable, - AGREEMENT_PLATFORM_USAGES, - ); - return new SecretKey(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)]; - }, - unwrapSecretKeyJwk: (input: UnwrapInput, options: AgreementKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - const policy = agreementPolicyOf(options); - requireAgreementGrant(policy); - const jwk = unwrappedJwk(bytes, "enc", agreementGrantedOps(policy)); - return redactingInvalidKey("unwrapped X25519 private JWK", () => x25519.importSecretKeyJwk(jwk, options)); - }, - unwrapSecretKeyPkcs8: (input: UnwrapInput, options: AgreementKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - "unwrapped X25519 pkcs8", - () => x25519.importSecretKeyPkcs8(bytes, options), - ); - }, -}; diff --git a/ports/webcrypto/src/keyWrap.ts b/ports/webcrypto/src/keyWrap.ts deleted file mode 100644 index 8310078..0000000 --- a/ports/webcrypto/src/keyWrap.ts +++ /dev/null @@ -1,253 +0,0 @@ -// `polymorph:webcrypto/key-wrap` plus `aes-kw` — wit/wrapping.wit -// `interface key-wrap`, wit/aes.wit `interface aes-kw`. -// -// Behavioral reference: js/jco/webcrypto.js:3097-3440. The platform's -// AES-KW operations wrap and unwrap `CryptoKey`s, not bytes, so both -// directions route the serialized material through a throwaway -// HMAC-SHA-256 `CryptoKey` whose raw import/export accepts any non-empty -// length; the wire format is RFC 3394's either way. - -import { errAuthenticationFailed, errInvalidKey, errOther, decryptFailure, notPermitted, platformCall } from "./errors.ts"; -import { asBufferSource, unwrappedJwk } from "./util.ts"; -import { - aesVariantByteLength, - exportJwkGated, - exportRawGated, - importPlatformKey, - importPlatformKeyJwk, - jwkKeyBytes, - jwkMaterial, - redactingInvalidKey, - requireStrictBase64url, -} from "./platform.ts"; -import { type DeriveInput, deriveKeyFrom } from "./derivation.ts"; -import { consumeUnwrapInput, consumeWrapInput, UnwrapInput, WrapInput } from "./wrapping.ts"; -import { grantedUsages } from "./errors.ts"; - -const subtle = globalThis.crypto.subtle; - -interface KwPolicy { - wrap: boolean; - unwrap: boolean; - extractable: boolean; -} - -const kwPolicies = new WeakMap(); - -function kwPolicyOf(o: KwKeyOptions): KwPolicy { - const p = kwPolicies.get(o); - if (p === undefined) errOther("kw-key-options minted by another provider"); - return p; -} - -/** `key-wrap.kw-key-options` (reference: webcrypto.js:3098). */ -export class KwKeyOptions { - constructor() { - kwPolicies.set(this, { wrap: false, unwrap: false, extractable: false }); - } - canWrap(allowed: boolean): void { - kwPolicyOf(this).wrap = allowed; - } - canUnwrap(allowed: boolean): void { - kwPolicyOf(this).unwrap = allowed; - } - extractable(allowed: boolean): void { - kwPolicyOf(this).extractable = allowed; - } -} - -/** One-to-one with the platform's own AES-KW usages (reference: webcrypto.js:3125). */ -function kwUsages(policy: KwPolicy): KeyUsage[] { - return grantedUsages([ - ["wrapKey", policy.wrap], - ["unwrapKey", policy.unwrap], - ]); -} - -function kwGrantedOps(policy: KwPolicy): string[] { - const ops: string[] = []; - if (policy.wrap) ops.push("wrapKey"); - if (policy.unwrap) ops.push("unwrapKey"); - return ops; -} - -/** `key-wrap.kw-key` (reference: webcrypto.js:3166). */ -export class KwKey { - #key: CryptoKey; - #lengthBits: number; - #grants: KwPolicy; - - constructor(key: CryptoKey, lengthBits: number, grants: KwPolicy) { - this.#key = key; - this.#lengthBits = lengthBits; - this.#grants = { ...grants }; - } - - /** - * Encrypt serialized key material (RFC 3394). JWK-format material is - * first padded with ASCII spaces to a multiple of 8 bytes — the `aes-kw` - * WIT contract, carried back by the JWK contract's trailing-space - * tolerance. Material outside the algorithm's input domain fails - * `invalid-key` with a fixed message: the material is not the caller's - * to see (reference: webcrypto.js:3188). - */ - async wrap(input: WrapInput): Promise { - const state = consumeWrapInput(input); - if (!this.canWrap()) notPermitted("wrap"); - let bytes = state.bytes; - if (state.format === "jwk" && bytes.length % 8 !== 0) { - const padded = new Uint8Array(bytes.length + 8 - (bytes.length % 8)); - padded.set(bytes); - padded.fill(0x20, bytes.length); - bytes = padded; - } - if (bytes.length % 8 !== 0 || bytes.length < 16) { - errInvalidKey("AES-KW wraps key material of at least 16 bytes, a multiple of 8"); - } - const trampoline = await platformCall("AES-KW wrap", () => - subtle.importKey("raw", asBufferSource(bytes), { name: "HMAC", hash: "SHA-256" }, true, ["sign"])); - const wrapped = await platformCall("AES-KW wrap", () => subtle.wrapKey("raw", trampoline, this.#key, "AES-KW")); - return new Uint8Array(wrapped); - } - - /** - * Decrypt and integrity-check wrapped material. Input that cannot carry - * the RFC 3394 wire format reports the same detail-free - * `authentication-failed` as an ICV failure, before the platform is - * asked — the two verdicts are deliberately indistinguishable - * (reference: webcrypto.js:3229). - */ - async unwrap(wrapped: Uint8Array): Promise { - if (!this.canUnwrap()) notPermitted("unwrap"); - if (wrapped.length % 8 !== 0 || wrapped.length < 24) { - errAuthenticationFailed(); - } - let trampoline: CryptoKey; - try { - trampoline = await subtle.unwrapKey( - "raw", - asBufferSource(wrapped), - this.#key, - "AES-KW", - { name: "HMAC", hash: "SHA-256" }, - true, - ["sign"], - ); - } catch (err) { - decryptFailure(err, "unwrap"); - } - const bytes = new Uint8Array( - await platformCall("AES-KW unwrap", () => subtle.exportKey("raw", trampoline)), - ); - return new UnwrapInput(bytes); - } - - algorithmName(): string { - return this.#key.algorithm.name; - } - algorithmLength(): number { - return this.#lengthBits; - } - extractable(): boolean { - return this.#grants.extractable; - } - canWrap(): boolean { - return this.#grants.wrap; - } - canUnwrap(): boolean { - return this.#grants.unwrap; - } - - exportKeyRaw(): Promise { - return exportRawGated(this.#key); - } - exportKeyJwk(): Promise { - return exportJwkGated(this.#key); - } - async toWrapInputRaw(): Promise { - return new WrapInput("raw", await exportRawGated(this.#key)); - } - async toWrapInputJwk(): Promise { - return new WrapInput("jwk", new TextEncoder().encode(await exportJwkGated(this.#key))); - } -} - -/** The `polymorph:webcrypto/key-wrap@0.1.0` interface: its resource classes. */ -export const keyWrap = { KwKey, KwKeyOptions }; - -/** The `polymorph:webcrypto/aes-kw@0.1.0` interface (reference: webcrypto.js:3317). */ -export const aesKw = { - async importKeyRaw(variant: string, raw: Uint8Array, options: KwKeyOptions): Promise { - const policy = kwPolicyOf(options); - const usages = kwUsages(policy); - const expected = aesVariantByteLength(variant); - if (raw.length !== expected) { - errInvalidKey(`${variant} requires ${expected} key bytes, got ${raw.length}`); - } - const key = await importPlatformKey( - `${variant} key`, - "raw", - raw, - { name: "AES-KW" }, - policy.extractable, - usages, - ); - return new KwKey(key, expected * 8, policy); - }, - - async importKeyJwk(variant: string, jwk: string, options: KwKeyOptions): Promise { - const policy = kwPolicyOf(options); - const usages = kwUsages(policy); - const lengthBits = aesVariantByteLength(variant) * 8; - const material = jwkMaterial(jwk); - requireStrictBase64url(material.k); - const key = await importPlatformKeyJwk( - `${variant} JWK`, - material, - { name: "AES-KW" }, - policy.extractable, - usages, - ); - const gotBits = jwkKeyBytes(material.k) * 8; - if (gotBits !== lengthBits) { - errInvalidKey(`JWK carries a ${gotBits}-bit key; ${variant} requires ${lengthBits}`); - } - return new KwKey(key, lengthBits, policy); - }, - - async generateKey(variant: string, options: KwKeyOptions): Promise { - const policy = kwPolicyOf(options); - const usages = kwUsages(policy); - 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); - }, - - async deriveKey(variant: string, input: DeriveInput, options: KwKeyOptions): Promise { - const policy = kwPolicyOf(options); - 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); - }, - - unwrapKeyRaw(variant: string, input: UnwrapInput, options: KwKeyOptions): Promise { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - `unwrapped ${variant} key material`, - () => aesKw.importKeyRaw(variant, bytes, options), - ); - }, - - unwrapKeyJwk(variant: string, input: UnwrapInput, options: KwKeyOptions): Promise { - const { bytes } = consumeUnwrapInput(input); - const policy = kwPolicyOf(options); - kwUsages(policy); - const jwk = unwrappedJwk(bytes, "enc", kwGrantedOps(policy)); - return redactingInvalidKey( - `unwrapped ${variant} JWK`, - () => aesKw.importKeyJwk(variant, jwk, options), - ); - }, -}; diff --git a/ports/webcrypto/src/mac.ts b/ports/webcrypto/src/mac.ts deleted file mode 100644 index 6451a69..0000000 --- a/ports/webcrypto/src/mac.ts +++ /dev/null @@ -1,272 +0,0 @@ -// `polymorph:webcrypto/mac` (algorithm-agnostic MAC key resource) plus -// `hmac-sha2` / `hmac-sha1` (the two minting interfaces) — wit/webcrypto.wit -// `interface mac`, wit/hmac.wit. - -import { - errAuthenticationFailed, - errInvalidKey, - errNotExtractable, - errNotPermitted, - errOther, - errUnsupported, - notPermitted, - platformCall, -} from "./errors.ts"; -import { importPlatformKeyJwk, jwkKeyBytes, jwkMaterial, requireStrictBase64url } from "./platform.ts"; -import { asBufferSource, unwrappedJwk, utf8Encode } from "./util.ts"; -import { deriveKeyFrom, type DeriveInput } from "./derivation.ts"; -import { consumeUnwrapInput, type UnwrapInput, WrapInput } from "./wrapping.ts"; -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; -import { collectByteStream } from "./util.ts"; - -const subtle = globalThis.crypto.subtle; - -interface MacPolicy { - sign: boolean; - verify: boolean; - extractable: boolean; -} - -const macPolicies = new WeakMap(); - -function policyOf(o: MacKeyOptions): MacPolicy { - const p = macPolicies.get(o); - if (p === undefined) { - errOther("mac-key-options minted by another provider"); - } - return p; -} - -/** `mac.mac-key-options`: mint-time policy, granting nothing by default. */ -export class MacKeyOptions { - constructor() { - macPolicies.set(this, { sign: false, verify: false, extractable: false }); - } - canSign(allowed: boolean): void { - policyOf(this).sign = allowed; - } - canVerify(allowed: boolean): void { - policyOf(this).verify = allowed; - } - extractable(allowed: boolean): void { - policyOf(this).extractable = allowed; - } -} - -function macUsages(policy: MacPolicy): KeyUsage[] { - const usages: KeyUsage[] = []; - if (policy.sign) usages.push("sign"); - if (policy.verify) usages.push("verify"); - if (usages.length === 0) { - errNotPermitted("a key with no enabled usage cannot be minted"); - } - return usages; -} - -interface HashSpec { - hash: string; - blockBytes: number; -} - -/** `mac.mac-key`: an HMAC key bound to a hash at mint. */ -export class MacKey { - #key: CryptoKey; - #lengthBits: number; - #hashName: string; - - constructor(key: CryptoKey, lengthBits: number, hashName: string) { - this.#key = key; - this.#lengthBits = lengthBits; - this.#hashName = hashName; - } - - extractable(): boolean { - return this.#key.extractable; - } - canSign(): boolean { - return this.#key.usages.includes("sign"); - } - canVerify(): boolean { - return this.#key.usages.includes("verify"); - } - algorithmName(): string { - return this.#key.algorithm.name; - } - algorithmHash(): string | undefined { - return this.#hashName; - } - algorithmLength(): number { - return this.#lengthBits; - } - - async sign(data: Stream): Promise { - const message = await collectByteStream(data); - if (!this.canSign()) notPermitted("sign"); - const out = await platformCall("HMAC sign", () => subtle.sign("HMAC", this.#key, asBufferSource(message))); - return new Uint8Array(out); - } - - async verify(data: Stream, tag: Uint8Array): Promise { - const message = await collectByteStream(data); - if (!this.canVerify()) notPermitted("verify"); - const ok = await platformCall("HMAC verify", () => - subtle.verify("HMAC", this.#key, asBufferSource(tag), asBufferSource(message))); - if (!ok) { - errAuthenticationFailed(); - } - } - - async exportKeyRaw(): Promise { - return exportRawGated(this.#key); - } - async exportKeyJwk(): Promise { - return exportJwkGated(this.#key); - } - async toWrapInputRaw(): Promise { - return new WrapInput("raw", await exportRawGated(this.#key)); - } - async toWrapInputJwk(): Promise { - const jwk = await exportJwkGated(this.#key); - return new WrapInput("jwk", utf8Encode(jwk)); - } -} - -/** The `error.not-extractable` gate shared by every export path (reference: js/jco/webcrypto.js `exportRawGated`-style helpers). */ -async function exportRawGated(key: CryptoKey): Promise { - if (!key.extractable) { - errNotExtractable(); - } - const raw = await platformCall("export raw", () => subtle.exportKey("raw", key)); - return new Uint8Array(raw); -} - -async function exportJwkGated(key: CryptoKey): Promise { - if (!key.extractable) { - errNotExtractable(); - } - const jwk = await platformCall("export jwk", () => subtle.exportKey("jwk", key)); - return JSON.stringify(jwk); -} - -/** The `polymorph:webcrypto/mac@0.1.0` interface: its resource classes. */ -export const mac = { MacKey, MacKeyOptions }; - -async function importHmacKey(resolved: HashSpec, raw: Uint8Array, options: MacKeyOptions): Promise { - const policy = policyOf(options); - const usages = macUsages(policy); - 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); -} - -async function generateHmacKey( - resolved: HashSpec, - length: number | undefined, - options: MacKeyOptions, -): Promise { - const policy = policyOf(options); - const usages = macUsages(policy); - if (length === 0) errInvalidKey("HMAC key length must be non-zero"); - if (length !== undefined && length % 8 !== 0) { - errUnsupported(`HMAC key length ${length} is not a multiple of 8; sub-byte lengths are not served`); - } - 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); -} - -/** - * Import an `oct` HMAC JWK (reference: js/jco/webcrypto.js:868-905). The - * material members go through `jwkMaterial` — `use`/`key_ops` are consumer - * policy and must NOT reach the platform, whose import would otherwise - * enforce them against the usages this host passes — and every platform - * refusal (a wrong `kty`, a mismatched `alg`, a malformed `k`) is - * `error.invalid-key`, the verdict the WIT pins for a bad JWK. - */ -async function importHmacKeyJwk(resolved: HashSpec, jwk: string, options: MacKeyOptions): Promise { - const policy = policyOf(options); - const usages = macUsages(policy); - const material = jwkMaterial(jwk); - requireStrictBase64url(material.k); - const key = await importPlatformKeyJwk( - "HMAC JWK", - material, - { name: "HMAC", hash: resolved.hash }, - policy.extractable, - usages, - ); - const kLen = typeof material.k === "string" ? jwkKeyBytes(material.k) * 8 : 0; - return new MacKey(key, kLen, resolved.hash); -} - -async function deriveHmacKey( - resolved: HashSpec, - input: DeriveInput, - length: number | undefined, - options: MacKeyOptions, -): Promise { - const policy = policyOf(options); - const usages = macUsages(policy); - if (length === 0) errInvalidKey("HMAC key length must be non-zero"); - if (length !== undefined && length % 8 !== 0) { - errUnsupported(`HMAC key length ${length} is not a multiple of 8; sub-byte lengths are not served`); - } - 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); -} - -function unwrapHmacKeyRaw(resolved: HashSpec, input: UnwrapInput, options: MacKeyOptions): Promise { - const { bytes } = consumeUnwrapInput(input); - return importHmacKey(resolved, bytes, options); -} - -function unwrapHmacKeyJwk(resolved: HashSpec, input: UnwrapInput, options: MacKeyOptions): Promise { - const { bytes } = consumeUnwrapInput(input); - const policy = policyOf(options); - const jwk = unwrappedJwk(bytes, "sig", macUsages(policy)); - return importHmacKeyJwk(resolved, jwk, options); -} - -const SHA1_HMAC: HashSpec = { hash: "SHA-1", blockBytes: 64 }; - -const SHA2_HMAC: Readonly> = Object.freeze({ - sha256: { hash: "SHA-256", blockBytes: 64 }, - sha384: { hash: "SHA-384", blockBytes: 128 }, - sha512: { hash: "SHA-512", blockBytes: 128 }, -}); - -function sha2Hmac(variant: string): HashSpec { - const spec = SHA2_HMAC[variant]; - if (spec === undefined) errUnsupported(`${variant} is not served by this implementation`); - return spec; -} - -/** The `polymorph:webcrypto/hmac-sha1@0.1.0` interface. */ -export const hmacSha1 = { - importKeyRaw: (raw: Uint8Array, options: MacKeyOptions) => importHmacKey(SHA1_HMAC, raw, options), - importKeyJwk: (jwk: string, options: MacKeyOptions) => importHmacKeyJwk(SHA1_HMAC, jwk, options), - generateKey: (length: number | undefined, options: MacKeyOptions) => generateHmacKey(SHA1_HMAC, length, options), - deriveKey: (input: DeriveInput, length: number | undefined, options: MacKeyOptions) => - deriveHmacKey(SHA1_HMAC, input, length, options), - unwrapKeyRaw: (input: UnwrapInput, options: MacKeyOptions) => unwrapHmacKeyRaw(SHA1_HMAC, input, options), - unwrapKeyJwk: (input: UnwrapInput, options: MacKeyOptions) => unwrapHmacKeyJwk(SHA1_HMAC, input, options), -}; - -/** The `polymorph:webcrypto/hmac-sha2@0.1.0` interface. */ -export const hmacSha2 = { - importKeyRaw: (variant: string, raw: Uint8Array, options: MacKeyOptions) => - importHmacKey(sha2Hmac(variant), raw, options), - importKeyJwk: (variant: string, jwk: string, options: MacKeyOptions) => - importHmacKeyJwk(sha2Hmac(variant), jwk, options), - generateKey: (variant: string, length: number | undefined, options: MacKeyOptions) => - generateHmacKey(sha2Hmac(variant), length, options), - deriveKey: (variant: string, input: DeriveInput, length: number | undefined, options: MacKeyOptions) => - deriveHmacKey(sha2Hmac(variant), input, length, options), - unwrapKeyRaw: (variant: string, input: UnwrapInput, options: MacKeyOptions) => - unwrapHmacKeyRaw(sha2Hmac(variant), input, options), - unwrapKeyJwk: (variant: string, input: UnwrapInput, options: MacKeyOptions) => - unwrapHmacKeyJwk(sha2Hmac(variant), input, options), -}; diff --git a/ports/webcrypto/src/mod.ts b/ports/webcrypto/src/mod.ts deleted file mode 100644 index a5a646d..0000000 --- a/ports/webcrypto/src/mod.ts +++ /dev/null @@ -1,132 +0,0 @@ -// The `polymorph:webcrypto@0.1.0` host-module port: `webcryptoImports()` -// returns the imports-record fragment for `instantiate` (contracts/ -// embedder-api.md §"Naming and casing": interface keys are the fully -// qualified WIT id, version included). -// -// Ported interfaces — the package's WHOLE surface as of 0.1.0: -// types (error taxonomy only; no exported functions), -// digest + sha2, sha1-checked (declined; see below), -// mac + hmac-sha1/hmac-sha2, -// signature + ed25519-verify/-sign, ecdsa-verify/-sign, -// rsassa-pkcs1-v15-verify/-sign, rsa-pss-verify/-sign, -// key-agreement + x25519, ecdh, -// derivation, wrapping, -// hkdf + hkdf-sha1/hkdf-sha2, pbkdf2 + pbkdf2-sha1/pbkdf2-sha2, -// aead + aes-gcm, cipher + aes-cbc/aes-ctr, key-wrap + aes-kw, -// public-encryption + rsa-oaep-encrypt/rsa-oaep-decrypt. -// -// Two standing declines, both the WIT's own rulings rather than Deno gaps: -// - `sha1-checked` is provided but fail-closed: its postures need sha1dc -// collision detection, which no platform WebCrypto carries (see -// sha1Checked.ts). Conformance targets declare it in missing-features. -// - `aes192`, `p521`/`p521-sha512`, and the truncated SHA-2 variants are -// declined package-wide with `error.unsupported` (see the WIT docs). -// -// The RSA private-key posture (`rsa-pss-sign`, `rsassa-pkcs1-v15-sign`, -// `rsa-oaep-decrypt`) defaults to SERVED here, matching the reference's -// Node posture; a browser-hosted embedding should call -// `setRsaPrivateKeyPolicy("decline")` — see rsaSignature.ts. - -import { digest, sha2 } from "./digest.ts"; -import { sha1Checked } from "./sha1Checked.ts"; -import { hmacSha1, hmacSha2, mac } from "./mac.ts"; -import { ed25519Sign, ed25519Verify, signature } from "./signature.ts"; -import { ecdsaSign, ecdsaVerify } from "./ecdsa.ts"; -import { rsaPssSign, rsaPssVerify, rsassaPkcs1V15Sign, rsassaPkcs1V15Verify } from "./rsaSignature.ts"; -import { keyAgreement, x25519 } from "./keyAgreement.ts"; -import { ecdh } from "./ecdh.ts"; -import { derivation } from "./derivation.ts"; -import { wrapping } from "./wrapping.ts"; -import { hkdf, hkdfSha1, hkdfSha2 } from "./hkdf.ts"; -import { pbkdf2, pbkdf2Sha1, pbkdf2Sha2 } from "./pbkdf2.ts"; -import { aead, aesGcm } from "./aead.ts"; -import { aesCbc, aesCtr, cipher } from "./cipher.ts"; -import { aesKw, keyWrap } from "./keyWrap.ts"; -import { publicEncryption, rsaOaepDecrypt, rsaOaepEncrypt } from "./publicEncryption.ts"; - -export { Digest, sha2 } from "./digest.ts"; -export { sha1Checked } from "./sha1Checked.ts"; -export { hmacSha1, hmacSha2, MacKey, MacKeyOptions } from "./mac.ts"; -export { - ed25519Sign, - ed25519Verify, - type SignatureAlgorithm, - SigningKey, - SigningKeyOptions, - VerifyingKey, -} from "./signature.ts"; -export { ecdsaSign, ecdsaVerify } from "./ecdsa.ts"; -export { - rsaPssSign, - rsaPssVerify, - rsassaPkcs1V15Sign, - rsassaPkcs1V15Verify, - setRsaPrivateKeyPolicy, -} from "./rsaSignature.ts"; -export { AgreementKeyOptions, keyAgreement, PublicKey, SecretKey, x25519 } from "./keyAgreement.ts"; -export { ecdh } from "./ecdh.ts"; -export { DeriveInput, DeriveOptions, derivation } from "./derivation.ts"; -export { UnwrapInput, WrapInput, wrapping } from "./wrapping.ts"; -export { hkdf, hkdfSha1, hkdfSha2, Ikm } from "./hkdf.ts"; -export { Password, pbkdf2, pbkdf2Sha1, pbkdf2Sha2 } from "./pbkdf2.ts"; -export { AeadKey, AeadKeyOptions, aead, aesGcm } from "./aead.ts"; -export { aesCbc, aesCtr, cipher, CipherKey, CipherKeyOptions } from "./cipher.ts"; -export { aesKw, keyWrap, KwKey, KwKeyOptions } from "./keyWrap.ts"; -export { - DecryptionKey, - DecryptionKeyOptions, - EncryptionKey, - publicEncryption, - rsaOaepDecrypt, - rsaOaepEncrypt, -} from "./publicEncryption.ts"; -export type { WcErrorPayload } from "./errors.ts"; - -/** - * Build the `polymorph:webcrypto@0.1.0` imports fragment for `instantiate`. - * - * Usage: `instantiate(artifacts, { ...wasiShims(), ...webcryptoImports() })` - * — the shape both conformance suites are driven with (see - * conformance/run.ts and tests/import_surface_test.ts, which asserts this - * record leaves ZERO import leaves unresolved for either suite). - */ -export function webcryptoImports(): Record { - return { - "polymorph:webcrypto/digest@0.1.0": digest, - "polymorph:webcrypto/sha2@0.1.0": sha2, - "polymorph:webcrypto/sha1-checked@0.1.0": sha1Checked, - "polymorph:webcrypto/mac@0.1.0": mac, - "polymorph:webcrypto/hmac-sha1@0.1.0": hmacSha1, - "polymorph:webcrypto/hmac-sha2@0.1.0": hmacSha2, - "polymorph:webcrypto/signature@0.1.0": signature, - "polymorph:webcrypto/ed25519-verify@0.1.0": ed25519Verify, - "polymorph:webcrypto/ed25519-sign@0.1.0": ed25519Sign, - "polymorph:webcrypto/ecdsa-verify@0.1.0": ecdsaVerify, - "polymorph:webcrypto/ecdsa-sign@0.1.0": ecdsaSign, - "polymorph:webcrypto/rsassa-pkcs1-v15-verify@0.1.0": rsassaPkcs1V15Verify, - "polymorph:webcrypto/rsassa-pkcs1-v15-sign@0.1.0": rsassaPkcs1V15Sign, - "polymorph:webcrypto/rsa-pss-verify@0.1.0": rsaPssVerify, - "polymorph:webcrypto/rsa-pss-sign@0.1.0": rsaPssSign, - "polymorph:webcrypto/key-agreement@0.1.0": keyAgreement, - "polymorph:webcrypto/x25519@0.1.0": x25519, - "polymorph:webcrypto/ecdh@0.1.0": ecdh, - "polymorph:webcrypto/derivation@0.1.0": derivation, - "polymorph:webcrypto/wrapping@0.1.0": wrapping, - "polymorph:webcrypto/hkdf@0.1.0": hkdf, - "polymorph:webcrypto/hkdf-sha2@0.1.0": hkdfSha2, - "polymorph:webcrypto/hkdf-sha1@0.1.0": hkdfSha1, - "polymorph:webcrypto/pbkdf2@0.1.0": pbkdf2, - "polymorph:webcrypto/pbkdf2-sha2@0.1.0": pbkdf2Sha2, - "polymorph:webcrypto/pbkdf2-sha1@0.1.0": pbkdf2Sha1, - "polymorph:webcrypto/aead@0.1.0": aead, - "polymorph:webcrypto/aes-gcm@0.1.0": aesGcm, - "polymorph:webcrypto/cipher@0.1.0": cipher, - "polymorph:webcrypto/aes-cbc@0.1.0": aesCbc, - "polymorph:webcrypto/aes-ctr@0.1.0": aesCtr, - "polymorph:webcrypto/key-wrap@0.1.0": keyWrap, - "polymorph:webcrypto/aes-kw@0.1.0": aesKw, - "polymorph:webcrypto/public-encryption@0.1.0": publicEncryption, - "polymorph:webcrypto/rsa-oaep-encrypt@0.1.0": rsaOaepEncrypt, - "polymorph:webcrypto/rsa-oaep-decrypt@0.1.0": rsaOaepDecrypt, - }; -} diff --git a/ports/webcrypto/src/pbkdf2.ts b/ports/webcrypto/src/pbkdf2.ts deleted file mode 100644 index 98808be..0000000 --- a/ports/webcrypto/src/pbkdf2.ts +++ /dev/null @@ -1,88 +0,0 @@ -// `polymorph:webcrypto/pbkdf2` plus `pbkdf2-sha1` / `pbkdf2-sha2` — -// wit/pbkdf2.wit. -// -// Behavioral reference: js/jco/webcrypto.js:1556-1665. Empty passwords are -// accepted (RFC 8018 admits an empty `P` and the platform serves it) — -// the documented asymmetry with `hkdf.import-ikm`. A zero iteration count -// fails at `prepare`, not at use, so a misparameterized input cannot mint. - -import { errOther, platformCall } from "./errors.ts"; -import { - DeriveInput, - type DeriveOptions, - type DerivePolicy, - deriveUsages, - mintDeriveInput, - readDerivePolicy, -} from "./derivation.ts"; -import { asBufferSource } from "./util.ts"; -import { redactingInvalidKey, served, SHA1_ENTRY, SHA2_VARIANTS } from "./platform.ts"; -import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; - -const subtle = globalThis.crypto.subtle; - -const passwordState = new WeakMap(); - -function passwordOf(p: Password): { key: CryptoKey; policy: DerivePolicy } { - const state = passwordState.get(p); - if (state === undefined) errOther("password minted by another provider"); - return state; -} - -/** - * `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). - */ -export class Password { - canDeriveBits(): boolean { - return passwordOf(this).policy.deriveBits; - } - canDeriveKey(): boolean { - return passwordOf(this).policy.deriveKey; - } -} - -async function importPassword(raw: Uint8Array, options: DeriveOptions): Promise { - const policy = readDerivePolicy(options); - 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; -} - -/** The `polymorph:webcrypto/pbkdf2@0.1.0` interface. */ -export const pbkdf2 = { - Password, - importPassword, - unwrapPassword: (input: UnwrapInput, options: DeriveOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey("unwrapped PBKDF2 password", () => importPassword(bytes, options)); - }, -}; - -/** `prepare` (reference: webcrypto.js:1615): salt and work factor bound now, output length per use. */ -function preparePbkdf2(hash: string, input: Password, salt: Uint8Array, iterations: number): DeriveInput { - if (iterations === 0) { - errOther("PBKDF2 requires a positive iteration count"); - } - const { key, policy } = passwordOf(input); - const params = { name: "PBKDF2", hash, salt: asBufferSource(salt.slice()), iterations }; - // PBKDF2 has no natural output length: every derivation is a caller - // choice, so `derive-bits(none)` is refused by `DeriveInput` itself. - return mintDeriveInput(key, params, policy, /* hasNaturalLength */ false); -} - -/** The `polymorph:webcrypto/pbkdf2-sha2@0.1.0` interface. */ -export const pbkdf2Sha2 = { - prepare: (variant: string, input: Password, salt: Uint8Array, iterations: number): Promise => - Promise.resolve(preparePbkdf2(served(SHA2_VARIANTS, variant).hash, input, salt, iterations)), -}; - -/** The `polymorph:webcrypto/pbkdf2-sha1@0.1.0` interface. */ -export const pbkdf2Sha1 = { - prepare: (input: Password, salt: Uint8Array, iterations: number): Promise => - Promise.resolve(preparePbkdf2(SHA1_ENTRY.hash, input, salt, iterations)), -}; diff --git a/ports/webcrypto/src/platform.ts b/ports/webcrypto/src/platform.ts deleted file mode 100644 index f8167cd..0000000 --- a/ports/webcrypto/src/platform.ts +++ /dev/null @@ -1,284 +0,0 @@ -// Shared platform-facing helpers for the `polymorph:webcrypto` port: the -// variant-table lookup, the gated exports, the JWK-material plumbing, and -// the two shallow DER guards the WIT contract pins ahead of the platform. -// -// Every function here is a direct port of the consumer's jco host module — -// the behavioral reference named by the mission — with `file:line` -// citations against `polymorph-webcrypto/js/jco/webcrypto.js`. Nothing -// here is fresh cryptographic engineering: the checks exist because the -// WIT pins verdicts the engines disagree on, and the reference is the -// authority for which verdict each is. - -import { errInvalidKey, errNotExtractable, errUnsupported, platformCall } from "./errors.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; -import { asBufferSource } from "./util.ts"; - -const subtle = globalThis.crypto.subtle; - -/** The served entry of a variant table, or `error.unsupported` (reference: webcrypto.js:286). */ -export function served(table: Readonly>, variant: string): T { - const entry = table[variant]; - if (entry === undefined) { - errUnsupported(`${variant} is not served by this implementation`); - } - return entry; -} - -/** The raw key length in bytes per served `aes-variant` (reference: webcrypto.js:2467; aes192 is declined package-wide by the WIT's own portability ruling). */ -export const AES_VARIANT_BYTES: Readonly> = Object.freeze({ - aes128: 16, - aes256: 32, -}); - -export function aesVariantByteLength(variant: string): number { - return served(AES_VARIANT_BYTES, variant); -} - -/** The mint-bound digest per served `sha2-variant` (reference: webcrypto.js:271). */ -export const SHA2_VARIANTS: Readonly> = Object - .freeze({ - sha256: { hash: "SHA-256", digestBytes: 32 }, - sha384: { hash: "SHA-384", digestBytes: 48 }, - sha512: { hash: "SHA-512", digestBytes: 64 }, - }); - -/** The SHA-1 HMAC/KDF entry (reference: webcrypto.js:953). */ -export const SHA1_ENTRY = Object.freeze({ hash: "SHA-1", digestBytes: 20 }); - -/** Rethrow a platform import failure as `error.invalid-key` (reference: webcrypto.js:4478). */ -function invalidKey(err: unknown, what: string): never { - const detail = err instanceof Error ? err.message : String(err); - errInvalidKey(`invalid ${what}: ${detail}`); -} - -/** Import binary key material; a platform refusal is `invalid-key` (reference: webcrypto.js:4495). */ -export async function importPlatformKey( - what: string, - format: "raw" | "spki" | "pkcs8", - bytes: Uint8Array, - // deno-lint-ignore no-explicit-any - algorithm: any, - extractable: boolean, - usages: KeyUsage[], -): Promise { - try { - return await subtle.importKey(format, asBufferSource(bytes), algorithm, extractable, usages); - } catch (err) { - if (err instanceof ComponentException) throw err; - invalidKey(err, what); - } -} - -/** Import a parsed JWK (a `jwkMaterial` result) (reference: webcrypto.js:4514). */ -export async function importPlatformKeyJwk( - what: string, - jwk: Record, - // deno-lint-ignore no-explicit-any - algorithm: any, - extractable: boolean, - usages: KeyUsage[], -): Promise { - try { - return await subtle.importKey("jwk", jwk as JsonWebKey, algorithm, extractable, usages); - } catch (err) { - if (err instanceof ComponentException) throw err; - invalidKey(err, what); - } -} - -/** `export-key-raw` behind the extractability gate (reference: webcrypto.js:3708). */ -export async function exportRawGated(key: CryptoKey): Promise { - if (!key.extractable) errNotExtractable(); - return new Uint8Array(await platformCall("raw key export", () => subtle.exportKey("raw", key))); -} - -/** The `oct` JWK, material members only, behind the same gate (reference: webcrypto.js:3721). */ -export async function exportJwkGated(key: CryptoKey): Promise { - if (!key.extractable) errNotExtractable(); - const jwk = await platformCall("jwk key export", () => subtle.exportKey("jwk", key)); - return JSON.stringify({ kty: jwk.kty, k: jwk.k, alg: jwk.alg }); -} - -/** The decoded byte length of a valid unpadded-base64url string (reference: webcrypto.js:3733). */ -export function jwkKeyBytes(k: unknown): number { - return typeof k === "string" ? Math.floor((k.length * 3) / 4) : 0; -} - -/** - * The base64url value (0-63) of a code unit, -1 outside the alphabet - * (reference: webcrypto.js:3748). Branchless sign-bit arithmetic: secret - * JWK members pass through here, so per-character work stays uniform. - */ -function b64urlValue(code: number): number { - const inRange = (lo: number, hi: number) => ((lo - 1 - code) & (code - hi - 1)) >>> 31; - const upper = inRange(0x41, 0x5a); - const lower = inRange(0x61, 0x7a); - const digit = inRange(0x30, 0x39); - const minus = inRange(0x2d, 0x2d); - const under = inRange(0x5f, 0x5f); - const valid = upper | lower | digit | minus | under; - return ( - upper * (code - 0x41) + - lower * (code - 0x61 + 26) + - digit * (code - 0x30 + 52) + - minus * 62 + - under * 63 - - (1 - valid) - ); -} - -/** Decode strict unpadded base64url, validated first (reference: webcrypto.js:3781). */ -export function b64urlDecode(text: string): Uint8Array { - const out = new Uint8Array(Math.floor((text.length * 3) / 4)); - let buffer = 0; - let bits = 0; - let at = 0; - for (let i = 0; i < text.length; i++) { - buffer = (buffer << 6) | b64urlValue(text.charCodeAt(i)); - bits += 6; - if (bits >= 8) { - bits -= 8; - out[at++] = (buffer >> bits) & 0xff; - } - } - return out; -} - -/** - * Enforce the contract's strict unpadded base64url on a JWK member before - * the platform sees it (reference: webcrypto.js:3825): engines are lenient - * here (Node accepts padding) and the WIT pins strictness, so - * implementations cannot diverge on adversarial input. Non-string members - * pass through — the platform rejects them with the right error shape. - */ -export function requireStrictBase64url(k: unknown): void { - if (typeof k !== "string") return; - if (k.length % 4 === 1) { - errInvalidKey("JWK member has an impossible base64url length"); - } - let invalid = 0; - let last = 0; - for (let i = 0; i < k.length; i++) { - const value = b64urlValue(k.charCodeAt(i)); - invalid |= value >> 31; - last = value; - } - if (invalid !== 0) { - errInvalidKey("JWK member is not unpadded base64url"); - } - const rem = k.length % 4; - if (rem !== 0) { - const mask = rem === 2 ? 0b1111 : 0b11; - if ((last & mask) !== 0) { - errInvalidKey("JWK member has non-zero trailing bits"); - } - } -} - -/** - * Parse JWK JSON text and strip the members the WIT contract ignores - * (reference: webcrypto.js:3863). `use`/`key_ops` are consumer policy and - * must not reach the platform, whose import would otherwise enforce them - * against the usages this host passes; `ext` stays (the platform validates - * it against `extractable`, which the WIT does model). - */ -export function jwkMaterial(jwkText: string): Record { - let jwk: unknown; - try { - jwk = JSON.parse(jwkText); - } catch (err) { - errInvalidKey(`JWK is not valid JSON: ${err}`); - } - if (typeof jwk !== "object" || jwk === null || Array.isArray(jwk)) { - errInvalidKey("JWK must be a JSON object"); - } - const { use: _use, key_ops: _keyOps, ...material } = jwk as Record; - return material; -} - -/** - * Run an unwrap mint's import body, redacting the detail of any - * `invalid-key` failure (reference: webcrypto.js:1227): the parse input is - * decrypted key material the caller does not hold, so the message must not - * carry any of it. - */ -export async function redactingInvalidKey(what: string, run: () => Promise): Promise { - try { - return await run(); - } catch (err) { - if (err instanceof ComponentException && (err.payload as { kind?: string })?.kind === "invalid-key") { - errInvalidKey(`invalid ${what}`); - } - throw err; - } -} - -/** - * The offset of the AlgorithmIdentifier TLV inside a SubjectPublicKeyInfo, - * or 0 when the input does not open as one (reference: webcrypto.js:2139). - */ -function spkiAlgorithmOffset(spki: Uint8Array): number { - if (spki.length >= 2 && spki[0] === 0x30) { - const first = spki[1]; - if (first < 0x80) return 2; - if (first === 0x81 && spki.length >= 3 && spki[2] >= 0x80) return 3; - if (first === 0x82 && spki.length >= 4 && spki[2] !== 0) return 4; - } - return 0; -} - -/** The named-curve AlgorithmIdentifier TLVs (reference: webcrypto.js:2118). */ -const EC_SPKI_ALGORITHM_IDENTIFIERS: Readonly> = Object.freeze({ - "P-256": Uint8Array.from([ - 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, - 0xce, 0x3d, 0x03, 0x01, 0x07, - ]), - "P-384": Uint8Array.from([ - 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x05, 0x2b, 0x81, 0x04, - 0x00, 0x22, - ]), -}); - -/** - * Reject an EC SubjectPublicKeyInfo whose AlgorithmIdentifier is not the - * declared curve's named-OID form (reference: webcrypto.js:2166). Engines - * split on explicit-ECParameters encodings and the WIT pins their - * rejection; the check is shallow and fail-closed, so it can only - * over-reject — whatever it passes still gets the platform's full DER - * validation. Vector coverage: the Wycheproof `UnnamedCurve` family. - */ -export function requireNamedCurveSpki(namedCurve: string, spki: Uint8Array): void { - const algorithm = EC_SPKI_ALGORITHM_IDENTIFIERS[namedCurve]; - const offset = spkiAlgorithmOffset(spki); - if (algorithm === undefined || offset === 0 || !algorithm.every((byte, i) => spki[offset + i] === byte)) { - errInvalidKey(`${namedCurve} SPKI must name the curve by OID`); - } -} - -/** `SEQUENCE { rsaEncryption, NULL }` (reference: webcrypto.js:2185). */ -const RSA_SPKI_ALGORITHM_IDENTIFIER = Uint8Array.from([ - 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, -]); - -/** - * Reject an RSA SubjectPublicKeyInfo that is not the `rsaEncryption` form - * (reference: webcrypto.js:2197): the family admission contract pins - * rejection of SPKIs carrying `id-RSASSA-PSS` parameters as `invalid-key`, - * uniformly regardless of engine behavior. - */ -export function requireRsaEncryptionSpki(spki: Uint8Array): void { - const offset = spkiAlgorithmOffset(spki); - if (offset === 0 || !RSA_SPKI_ALGORITHM_IDENTIFIER.every((byte, i) => spki[offset + i] === byte)) { - errInvalidKey("RSA SPKI must carry the rsaEncryption AlgorithmIdentifier"); - } -} - -/** The fixed 12-byte RFC 8410 SPKI prefix check (reference: webcrypto.js:3808). */ -export function rfc8410SpkiKey(oidTail: number, spki: Uint8Array, what: string): Uint8Array { - const prefix = [0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, oidTail, 0x03, 0x21, 0x00]; - const ok = spki.length === 44 && prefix.every((byte, i) => spki[i] === byte); - if (!ok) { - errInvalidKey(`${what}: not an RFC 8410 SubjectPublicKeyInfo`); - } - return spki.slice(12); -} diff --git a/ports/webcrypto/src/publicEncryption.ts b/ports/webcrypto/src/publicEncryption.ts deleted file mode 100644 index 9bf3ee0..0000000 --- a/ports/webcrypto/src/publicEncryption.ts +++ /dev/null @@ -1,409 +0,0 @@ -// `polymorph:webcrypto/public-encryption` plus `rsa-oaep-encrypt` / -// `rsa-oaep-decrypt` — wit/encryption.wit, wit/rsa.wit. -// -// Behavioral reference: js/jco/webcrypto.js:5367-6050. -// -// The decryption side's single-verdict rule is load-bearing: RFC 8017 -// requires a wrong-length ciphertext, damaged padding, and a mismatched -// label to be INDISTINGUISHABLE, so every platform failure collapses to -// the detail-free `error.authentication-failed` — deliberately not the -// AEAD kinds' `decryptFailure`, whose classification is exactly the -// verdict distinction this kind's contract closes off. - -import { - errAuthenticationFailed, - errInvalidKey, - errNotExtractable, - errOther, - errUnsupported, - grantedUsages, - notPermitted, - platformCall, - witError, -} from "./errors.ts"; -import { asBufferSource, unwrappedJwk } from "./util.ts"; -import { - importPlatformKey, - importPlatformKeyJwk, - jwkMaterial, - redactingInvalidKey, - requireRsaEncryptionSpki, - requireStrictBase64url, - served, -} from "./platform.ts"; -import { - RSA_MODULUS_BITS, - RSA_VARIANTS, - rsaAdmittedModulusLength, - requireRsaJwkAlg, - requireRsaPrivateKeysServed, - rsaJwkAlgPrefix, -} from "./rsaSignature.ts"; -import { consumeUnwrapInput, consumeWrapInput, UnwrapInput, WrapInput } from "./wrapping.ts"; - -const subtle = globalThis.crypto.subtle; - -/** The RSA-OAEP admission window: encryption creates FUTURE artifacts, so there is no legacy tier (reference: webcrypto.js:5760). */ -const RSA_OAEP_MIN_BITS = 2048; -const RSA_OAEP_MAX_BITS = 8192; - -interface OaepAlgorithm { - hash: string; - modulusLength: number; - /** RFC 8017 §7.1.1's `k − 2·hLen − 2`, enforced host-side. */ - plaintextBound: number; -} - -/** The shared RSA admission checks at the OAEP window. */ -function rsaOaepAdmitted(key: CryptoKey, what: string): number { - return rsaAdmittedModulusLength(key, what, RSA_OAEP_MIN_BITS, RSA_OAEP_MAX_BITS); -} - -function oaepAlgorithm(entry: { hash: string; digestBytes: number }, modulusLength: number): OaepAlgorithm { - return { - hash: entry.hash, - modulusLength, - plaintextBound: Math.ceil(modulusLength / 8) - 2 * entry.digestBytes - 2, - }; -} - -/** The named plaintext-bound condition: the signal to switch to hybrid wrapping (reference: webcrypto.js:5461). */ -function errMessageTooLong(what: string, length: number, algorithm: OaepAlgorithm): never { - witError({ - kind: "extension", - value: { - origin: "polymorph:webcrypto", - name: "message-too-long", - message: `${what} is ${length} bytes; this key's RSA-OAEP bound is ${algorithm.plaintextBound}`, - }, - }); -} - -/** `RsaOaepParams` for a per-call label; OAEP's default label is empty, so absent and empty are interchangeable. */ -function oaepParams(label: Uint8Array | undefined): RsaOaepParams { - return label === undefined - ? { name: "RSA-OAEP" } - : { name: "RSA-OAEP", label: asBufferSource(label) }; -} - -/** Every decryption failure is the one detail-free verdict (reference: webcrypto.js:5502). */ -async function oaepDecrypt( - key: CryptoKey, - label: Uint8Array | undefined, - ciphertext: Uint8Array, -): Promise { - try { - return new Uint8Array(await subtle.decrypt(oaepParams(label), key, asBufferSource(ciphertext))); - } catch { - errAuthenticationFailed(); - } -} - -/** `public-encryption.encryption-key`: public, secret-free, grant-free. */ -export class EncryptionKey { - #key: CryptoKey; - #algorithm: OaepAlgorithm; - - constructor(key: CryptoKey, algorithm: OaepAlgorithm) { - this.#key = key; - this.#algorithm = algorithm; - } - - async encrypt(label: Uint8Array | undefined, plaintext: Uint8Array): Promise { - if (plaintext.length > this.#algorithm.plaintextBound) { - errMessageTooLong("plaintext", plaintext.length, this.#algorithm); - } - const out = await platformCall("RSA-OAEP encrypt", () => - subtle.encrypt(oaepParams(label), this.#key, asBufferSource(plaintext))); - return new Uint8Array(out); - } - - async wrap(label: Uint8Array | undefined, input: WrapInput): Promise { - const { bytes } = consumeWrapInput(input); - if (bytes.length > this.#algorithm.plaintextBound) { - errMessageTooLong("wrapped key material", bytes.length, this.#algorithm); - } - const out = await platformCall("RSA-OAEP wrap", () => - subtle.encrypt(oaepParams(label), this.#key, asBufferSource(bytes))); - return new Uint8Array(out); - } - - algorithmName(): string { - return this.#key.algorithm.name; - } - algorithmHash(): string | undefined { - return this.#algorithm.hash; - } - algorithmLength(): number | undefined { - return this.#algorithm.modulusLength; - } - algorithmPublicExponent(): Uint8Array | undefined { - const e = (this.#key.algorithm as RsaHashedKeyAlgorithm).publicExponent; - return e === undefined ? undefined : new Uint8Array(e); - } - - exportKeyRaw(): Promise { - errUnsupported("RSA public keys have no raw form"); - } - async exportKeySpki(): Promise { - const spki = await platformCall("spki key export", () => subtle.exportKey("spki", this.#key)); - return new Uint8Array(spki); - } - async exportKeyJwk(): Promise { - const jwk = await platformCall("jwk key export", () => subtle.exportKey("jwk", this.#key)); - return JSON.stringify({ kty: jwk.kty, n: jwk.n, e: jwk.e }); - } -} - -interface DecryptionPolicy { - decrypt: boolean; - unwrap: boolean; - extractable: boolean; -} - -const decryptionPolicies = new WeakMap(); - -function decryptionPolicyOf(o: DecryptionKeyOptions): DecryptionPolicy { - const p = decryptionPolicies.get(o); - if (p === undefined) errOther("decryption-key-options minted by another provider"); - return p; -} - -/** `public-encryption.decryption-key-options`. */ -export class DecryptionKeyOptions { - constructor() { - decryptionPolicies.set(this, { decrypt: false, unwrap: false, extractable: false }); - } - canDecrypt(allowed: boolean): void { - decryptionPolicyOf(this).decrypt = allowed; - } - canUnwrap(allowed: boolean): void { - decryptionPolicyOf(this).unwrap = allowed; - } - extractable(allowed: boolean): void { - decryptionPolicyOf(this).extractable = allowed; - } -} - -/** Both WIT grants run `subtle.decrypt`, so they collapse onto one platform usage (reference: webcrypto.js:5400). */ -function oaepPrivateUsages(policy: DecryptionPolicy): KeyUsage[] { - return grantedUsages([["decrypt", policy.decrypt || policy.unwrap]]); -} - -/** The granted operations' platform names, for the unwrap-path `key_ops` rule (reference: webcrypto.js:5420). */ -function oaepGrantedOps(policy: DecryptionPolicy): string[] { - const ops: string[] = []; - if (policy.decrypt) ops.push("decrypt"); - if (policy.unwrap) ops.push("unwrapKey"); - return ops; -} - -/** `public-encryption.decryption-key`. */ -export class DecryptionKey { - #key: CryptoKey; - #algorithm: OaepAlgorithm; - #grants: DecryptionPolicy; - - constructor(key: CryptoKey, algorithm: OaepAlgorithm, grants: DecryptionPolicy) { - this.#key = key; - this.#algorithm = algorithm; - this.#grants = { ...grants }; - } - - decrypt(label: Uint8Array | undefined, ciphertext: Uint8Array): Promise { - if (!this.canDecrypt()) notPermitted("decrypt"); - return oaepDecrypt(this.#key, label, ciphertext); - } - - async unwrap(label: Uint8Array | undefined, ciphertext: Uint8Array): Promise { - if (!this.canUnwrap()) notPermitted("unwrap"); - return new UnwrapInput(await oaepDecrypt(this.#key, label, ciphertext)); - } - - algorithmName(): string { - return this.#key.algorithm.name; - } - algorithmHash(): string | undefined { - return this.#algorithm.hash; - } - algorithmLength(): number | undefined { - return this.#algorithm.modulusLength; - } - algorithmPublicExponent(): Uint8Array | undefined { - const e = (this.#key.algorithm as RsaHashedKeyAlgorithm).publicExponent; - return e === undefined ? undefined : new Uint8Array(e); - } - canDecrypt(): boolean { - return this.#grants.decrypt; - } - canUnwrap(): boolean { - return this.#grants.unwrap; - } - extractable(): boolean { - return this.#key.extractable; - } - - async exportKeyJwk(): Promise { - if (!this.#key.extractable) errNotExtractable(); - const jwk = await platformCall("jwk key export", () => subtle.exportKey("jwk", this.#key)); - return JSON.stringify({ - kty: jwk.kty, - n: jwk.n, - e: jwk.e, - d: jwk.d, - p: jwk.p, - q: jwk.q, - dp: jwk.dp, - dq: jwk.dq, - qi: jwk.qi, - }); - } - async exportKeyPkcs8(): Promise { - if (!this.#key.extractable) errNotExtractable(); - const pkcs8 = await platformCall("pkcs8 key export", () => subtle.exportKey("pkcs8", this.#key)); - return new Uint8Array(pkcs8); - } - async toWrapInputJwk(): Promise { - return new WrapInput("jwk", new TextEncoder().encode(await this.exportKeyJwk())); - } - async toWrapInputPkcs8(): Promise { - return new WrapInput("pkcs8", await this.exportKeyPkcs8()); - } -} - -/** The `polymorph:webcrypto/public-encryption@0.1.0` interface: its resource classes. */ -export const publicEncryption = { EncryptionKey, DecryptionKey, DecryptionKeyOptions }; - -/** The `polymorph:webcrypto/rsa-oaep-encrypt@0.1.0` interface. */ -export const rsaOaepEncrypt = { - importEncryptionKeySpki: async (variant: string, spki: Uint8Array): Promise => { - const entry = served(RSA_VARIANTS, variant); - requireRsaEncryptionSpki(spki); - const key = await importPlatformKey( - "RSA-OAEP spki", - "spki", - spki, - { name: "RSA-OAEP", hash: entry.hash }, - true, - ["encrypt"], - ); - const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP spki"); - return new EncryptionKey(key, oaepAlgorithm(entry, modulusLength)); - }, - importEncryptionKeyJwk: async (variant: string, jwkText: string): Promise => { - const entry = served(RSA_VARIANTS, variant); - const jwk = jwkMaterial(jwkText); - requireRsaJwkAlg(rsaJwkAlgPrefix("RSA-OAEP"), variant, jwk); - requireStrictBase64url(jwk.n); - requireStrictBase64url(jwk.e); - const key = await importPlatformKeyJwk( - "RSA-OAEP public JWK", - jwk, - { name: "RSA-OAEP", hash: entry.hash }, - true, - ["encrypt"], - ); - const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP public JWK"); - return new EncryptionKey(key, oaepAlgorithm(entry, modulusLength)); - }, -}; - -/** The `polymorph:webcrypto/rsa-oaep-decrypt@0.1.0` interface. */ -export const rsaOaepDecrypt = { - generateKey: async ( - variant: string, - modulus: string, - options: DecryptionKeyOptions, - ): Promise<[DecryptionKey, EncryptionKey]> => { - requireRsaPrivateKeysServed(); - const policy = decryptionPolicyOf(options); - oaepPrivateUsages(policy); - const entry = served(RSA_VARIANTS, variant); - const modulusLength = served(RSA_MODULUS_BITS, modulus); - const pair = await platformCall("RSA-OAEP key generation", () => - subtle.generateKey( - { name: "RSA-OAEP", hash: entry.hash, modulusLength, publicExponent: new Uint8Array([1, 0, 1]) }, - policy.extractable, - ["encrypt", "decrypt"], - )) as CryptoKeyPair; - const algorithm = oaepAlgorithm(entry, modulusLength); - return [ - new DecryptionKey(pair.privateKey, algorithm, policy), - new EncryptionKey(pair.publicKey, algorithm), - ]; - }, - - importDecryptionKeyPkcs8: async ( - variant: string, - pkcs8: Uint8Array, - options: DecryptionKeyOptions, - ): Promise => { - requireRsaPrivateKeysServed(); - const policy = decryptionPolicyOf(options); - const usages = oaepPrivateUsages(policy); - const entry = served(RSA_VARIANTS, variant); - const key = await importPlatformKey( - "RSA-OAEP pkcs8", - "pkcs8", - pkcs8, - { name: "RSA-OAEP", hash: entry.hash }, - policy.extractable, - usages, - ); - const modulusLength = rsaOaepAdmitted(key, "RSA-OAEP pkcs8"); - return new DecryptionKey(key, oaepAlgorithm(entry, modulusLength), policy); - }, - - importDecryptionKeyJwk: async ( - variant: string, - jwkText: string, - options: DecryptionKeyOptions, - ): Promise => { - requireRsaPrivateKeysServed(); - const policy = decryptionPolicyOf(options); - const usages = oaepPrivateUsages(policy); - const entry = served(RSA_VARIANTS, variant); - const jwk = jwkMaterial(jwkText); - requireRsaJwkAlg(rsaJwkAlgPrefix("RSA-OAEP"), variant, jwk); - for (const member of ["n", "e", "d", "p", "q", "dp", "dq", "qi"]) { - requireStrictBase64url(jwk[member]); - } - const key = await importPlatformKeyJwk( - "RSA-OAEP private JWK", - jwk, - { name: "RSA-OAEP", hash: entry.hash }, - policy.extractable, - usages, - ); - 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); - }, - - unwrapDecryptionKeyPkcs8: ( - variant: string, - input: UnwrapInput, - options: DecryptionKeyOptions, - ): Promise => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - "unwrapped RSA-OAEP pkcs8", - () => rsaOaepDecrypt.importDecryptionKeyPkcs8(variant, bytes, options), - ); - }, - - unwrapDecryptionKeyJwk: ( - variant: string, - input: UnwrapInput, - options: DecryptionKeyOptions, - ): Promise => { - const { bytes } = consumeUnwrapInput(input); - const policy = decryptionPolicyOf(options); - oaepPrivateUsages(policy); - const jwk = unwrappedJwk(bytes, "enc", oaepGrantedOps(policy)); - return redactingInvalidKey( - "unwrapped RSA-OAEP private JWK", - () => rsaOaepDecrypt.importDecryptionKeyJwk(variant, jwk, options), - ); - }, -}; diff --git a/ports/webcrypto/src/rsaSignature.ts b/ports/webcrypto/src/rsaSignature.ts deleted file mode 100644 index 5ad59a9..0000000 --- a/ports/webcrypto/src/rsaSignature.ts +++ /dev/null @@ -1,304 +0,0 @@ -// The RSA signature families: `rsassa-pkcs1-v15-verify` / `-sign` and -// `rsa-pss-verify` / `-sign` — wit/rsa.wit. The resources are -// `signature`'s; this module supplies the shared admission rules and the -// minting paths. -// -// Behavioral reference: js/jco/webcrypto.js:4930-5365. -// -// Two admission facts the platform does not enforce and the WIT does -// (reference: webcrypto.js:4932, checked on the IMPORTED key's metadata, -// so no DER or JWK parsing is needed): the modulus length must lie in the -// caller's window, and the public exponent must be odd and at least 3. - -import { errInvalidKey, errUnsupported, platformCall } from "./errors.ts"; -import { - importPlatformKey, - importPlatformKeyJwk, - jwkMaterial, - redactingInvalidKey, - requireRsaEncryptionSpki, - requireStrictBase64url, - served, - SHA2_VARIANTS, -} from "./platform.ts"; -import { - requireSigningGrant, - type SignatureAlgorithm, - SigningKey, - type SigningKeyOptions, - signingPolicyOf, - VerifyingKey, -} from "./signature.ts"; -import { consumeUnwrapInput, type UnwrapInput } from "./wrapping.ts"; -import { unwrappedJwk } from "./util.ts"; - -const subtle = globalThis.crypto.subtle; - -/** The `rsa-variant` table: RSA's parameterization is the digest alone (SHA-1 is deliberately absent). */ -export const RSA_VARIANTS = SHA2_VARIANTS; - -/** The family's admission window, in bits (reference: webcrypto.js:4877). */ -const RSA_MODULUS_MIN_BITS = 1024; -const RSA_MODULUS_MAX_BITS = 16384; -/** The signing interfaces' tightened window (reference: webcrypto.js:4881). */ -export const RSA_SIGNING_MIN_BITS = 2048; -export const RSA_SIGNING_MAX_BITS = 8192; - -/** The generated modulus length per `rsa-modulus` case (reference: webcrypto.js:4888). */ -export const RSA_MODULUS_BITS: Readonly> = Object.freeze({ - m2048: 2048, - m3072: 3072, - m4096: 4096, - m8192: 8192, -}); - -/** The admission checks the platform omits, run on the imported key's metadata (reference: webcrypto.js:4932). */ -export function rsaAdmittedModulusLength( - key: CryptoKey, - what: string, - minBits: number = RSA_MODULUS_MIN_BITS, - maxBits: number = RSA_MODULUS_MAX_BITS, -): number { - const { modulusLength, publicExponent } = key.algorithm as RsaHashedKeyAlgorithm; - if (modulusLength < minBits || modulusLength > maxBits) { - errInvalidKey(`invalid ${what}: RSA modulus must be ${minBits}-${maxBits} bits, got ${modulusLength}`); - } - // `publicExponent` is the big-endian magnitude; leading zeros only make - // the octet count larger, never the value. - let first = 0; - while (first < publicExponent.length && publicExponent[first] === 0) first++; - const octets = publicExponent.length - first; - const low = octets === 0 ? 0 : publicExponent[publicExponent.length - 1]; - if ((low & 1) === 0 || (octets === 1 && low < 3)) { - errInvalidKey(`invalid ${what}: RSA public exponent must be odd and at least 3`); - } - return modulusLength; -} - -/** The mint-bound record for an admitted RSA key (reference: webcrypto.js:4966). */ -export function rsaAlgorithm( - name: string, - hash: string, - modulusLength: number, - saltLength: number | undefined, -): SignatureAlgorithm { - return { - name, - namedCurve: undefined, - hash, - length: modulusLength, - signatureLength: Math.ceil(modulusLength / 8), - saltLength, - }; -} - -/** RSA-PSS signing fixes the salt length to the digest length (the JOSE `PS*` profile; reference: webcrypto.js:5128). */ -function rsaSigningAlgorithm( - name: string, - entry: { hash: string; digestBytes: number }, - modulusLength: number, -): SignatureAlgorithm { - return rsaAlgorithm(name, entry.hash, modulusLength, name === "RSA-PSS" ? entry.digestBytes : undefined); -} - -/** - * The RSA private-key posture (reference: webcrypto.js:5091-5127). The - * reference declines RSA private-key minting outside Node, because those - * operations leak key material through execution timing unless the - * implementation is constant-time end to end, and a browser is the - * archetypal attacker-observable timing domain. - * - * CONTRACT: this port runs under Deno — a server runtime whose co-tenancy - * the deployer chooses, like Node — so the default here is `"serve"`, the - * reference's Node posture. A browser-hosted embedding of this port should - * call `setRsaPrivateKeyPolicy("decline")`; the decline then surfaces as - * `error.unsupported` and the consumer declares the gated features - * missing on that target. - */ -let rsaPrivateKeyPolicy: "serve" | "decline" = "serve"; - -export function setRsaPrivateKeyPolicy(policy: "serve" | "decline"): void { - rsaPrivateKeyPolicy = policy; -} - -export function requireRsaPrivateKeysServed(): void { - if (rsaPrivateKeyPolicy !== "serve") { - errUnsupported("RSA private-key operations are declined in this environment; see setRsaPrivateKeyPolicy"); - } -} - -async function importRsaVerifyingKeySpki( - name: string, - variant: string, - spki: Uint8Array, - saltLength: number | undefined, -): Promise { - const { hash } = served(RSA_VARIANTS, variant); - requireRsaEncryptionSpki(spki); - const key = await importPlatformKey(`${name} spki`, "spki", spki, { name, hash }, true, ["verify"]); - const modulusLength = rsaAdmittedModulusLength(key, `${name} spki`); - return new VerifyingKey(key, rsaAlgorithm(name, hash, modulusLength, saltLength)); -} - -/** - * The JOSE `alg` an RSA JWK must name EXACTLY, when present, for a given - * family and variant (the WIT pins the spelling: `RS*` for - * RSASSA-PKCS1-v1_5, `PS*` for RSA-PSS, `RSA-OAEP-*` for OAEP). Checked - * host-side because Deno's import ignores a mismatched — and even a - * wrong-CASE — `alg` where Node refuses it. - */ -export function requireRsaJwkAlg(prefix: string, variant: string, jwk: Record): void { - const alg = jwk.alg; - if (alg === undefined) return; - const bits = { sha256: "256", sha384: "384", sha512: "512" }[variant]; - if (bits === undefined) return; - const expected = `${prefix}${bits}`; - if (alg !== expected) { - errInvalidKey(`RSA JWK declares alg ${String(alg)}; this variant uses ${expected}`); - } -} - -/** The JOSE `alg` prefix per WebCrypto algorithm name. */ -export function rsaJwkAlgPrefix(name: string): string { - return name === "RSA-PSS" ? "PS" : name === "RSA-OAEP" ? "RSA-OAEP-" : "RS"; -} - -async function importRsaVerifyingKeyJwk( - name: string, - variant: string, - jwkText: string, - saltLength: number | undefined, -): Promise { - const { hash } = served(RSA_VARIANTS, variant); - const jwk = jwkMaterial(jwkText); - requireRsaJwkAlg(rsaJwkAlgPrefix(name), variant, jwk); - requireStrictBase64url(jwk.n); - requireStrictBase64url(jwk.e); - const key = await importPlatformKeyJwk(`${name} public JWK`, jwk, { name, hash }, true, ["verify"]); - const modulusLength = rsaAdmittedModulusLength(key, `${name} public JWK`); - return new VerifyingKey(key, rsaAlgorithm(name, hash, modulusLength, saltLength)); -} - -/** The `polymorph:webcrypto/rsassa-pkcs1-v15-verify@0.1.0` interface. */ -export const rsassaPkcs1V15Verify = { - importVerifyingKeySpki: (variant: string, spki: Uint8Array): Promise => - importRsaVerifyingKeySpki("RSASSA-PKCS1-v1_5", variant, spki, undefined), - importVerifyingKeyJwk: (variant: string, jwk: string): Promise => - importRsaVerifyingKeyJwk("RSASSA-PKCS1-v1_5", variant, jwk, undefined), -}; - -/** The `polymorph:webcrypto/rsa-pss-verify@0.1.0` interface. */ -export const rsaPssVerify = { - importVerifyingKeySpki: (variant: string, saltLength: number, spki: Uint8Array): Promise => - importRsaVerifyingKeySpki("RSA-PSS", variant, spki, saltLength), - importVerifyingKeyJwk: (variant: string, saltLength: number, jwk: string): Promise => - importRsaVerifyingKeyJwk("RSA-PSS", variant, jwk, saltLength), -}; - -async function generateRsaSigningKey( - name: string, - variant: string, - modulus: string, - options: SigningKeyOptions, -): Promise<[SigningKey, VerifyingKey]> { - requireRsaPrivateKeysServed(); - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const entry = served(RSA_VARIANTS, variant); - const modulusLength = served(RSA_MODULUS_BITS, modulus); - const pair = await platformCall(`${name} key generation`, () => - subtle.generateKey( - { name, hash: entry.hash, modulusLength, publicExponent: new Uint8Array([1, 0, 1]) }, - policy.extractable, - ["sign", "verify"], - )) as CryptoKeyPair; - const algorithm = rsaSigningAlgorithm(name, entry, modulusLength); - return [new SigningKey(pair.privateKey, algorithm), new VerifyingKey(pair.publicKey, algorithm)]; -} - -async function importRsaSigningKeyPkcs8( - name: string, - variant: string, - pkcs8: Uint8Array, - options: SigningKeyOptions, -): Promise { - requireRsaPrivateKeysServed(); - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const entry = served(RSA_VARIANTS, variant); - const key = await importPlatformKey( - `${name} pkcs8`, - "pkcs8", - pkcs8, - { name, hash: entry.hash }, - policy.extractable, - ["sign"], - ); - const modulusLength = rsaAdmittedModulusLength(key, `${name} pkcs8`, RSA_SIGNING_MIN_BITS, RSA_SIGNING_MAX_BITS); - return new SigningKey(key, rsaSigningAlgorithm(name, entry, modulusLength)); -} - -async function importRsaSigningKeyJwk( - name: string, - variant: string, - jwkText: string, - options: SigningKeyOptions, -): Promise { - requireRsaPrivateKeysServed(); - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const entry = served(RSA_VARIANTS, variant); - const jwk = jwkMaterial(jwkText); - requireRsaJwkAlg(rsaJwkAlgPrefix(name), variant, jwk); - for (const member of ["n", "e", "d", "p", "q", "dp", "dq", "qi"]) { - requireStrictBase64url(jwk[member]); - } - const key = await importPlatformKeyJwk( - `${name} private JWK`, - jwk, - { name, hash: entry.hash }, - policy.extractable, - ["sign"], - ); - if (key.type !== "private") errInvalidKey("RSA private JWK must carry `d` and the CRT members"); - const modulusLength = rsaAdmittedModulusLength( - key, - `${name} private JWK`, - RSA_SIGNING_MIN_BITS, - RSA_SIGNING_MAX_BITS, - ); - return new SigningKey(key, rsaSigningAlgorithm(name, entry, modulusLength)); -} - -function rsaSigningInterface(name: "RSASSA-PKCS1-v1_5" | "RSA-PSS") { - return { - generateKey: (variant: string, modulus: string, options: SigningKeyOptions) => - generateRsaSigningKey(name, variant, modulus, options), - importSigningKeyPkcs8: (variant: string, pkcs8: Uint8Array, options: SigningKeyOptions) => - importRsaSigningKeyPkcs8(name, variant, pkcs8, options), - importSigningKeyJwk: (variant: string, jwk: string, options: SigningKeyOptions) => - importRsaSigningKeyJwk(name, variant, jwk, options), - unwrapSigningKeyPkcs8: (variant: string, input: UnwrapInput, options: SigningKeyOptions) => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - `unwrapped ${name} pkcs8`, - () => importRsaSigningKeyPkcs8(name, variant, bytes, options), - ); - }, - unwrapSigningKeyJwk: (variant: string, input: UnwrapInput, options: SigningKeyOptions) => { - const { bytes } = consumeUnwrapInput(input); - requireSigningGrant(signingPolicyOf(options)); - const jwk = unwrappedJwk(bytes, "sig", ["sign"]); - return redactingInvalidKey( - `unwrapped ${name} private JWK`, - () => importRsaSigningKeyJwk(name, variant, jwk, options), - ); - }, - }; -} - -/** The `polymorph:webcrypto/rsassa-pkcs1-v15-sign@0.1.0` interface. */ -export const rsassaPkcs1V15Sign = rsaSigningInterface("RSASSA-PKCS1-v1_5"); - -/** The `polymorph:webcrypto/rsa-pss-sign@0.1.0` interface. */ -export const rsaPssSign = rsaSigningInterface("RSA-PSS"); diff --git a/ports/webcrypto/src/sha1Checked.ts b/ports/webcrypto/src/sha1Checked.ts deleted file mode 100644 index bfd9857..0000000 --- a/ports/webcrypto/src/sha1Checked.ts +++ /dev/null @@ -1,29 +0,0 @@ -// `polymorph:webcrypto/sha1-checked` — wit/sha1.wit, `@unstable(feature = -// sha1-checked)`. -// -// This interface is fail-closed by construction here. Its two postures -// (rejecting / mitigating) both require sha1dc collision detection, which -// NO platform WebCrypto carries — the WIT's own note: "platform-backed -// providers cannot serve this interface". A platform-backed host that -// answered anyway would be returning a plain SHA-1 digest under a name -// that promises attack detection, so both mints decline with -// `error.unsupported` instead (reference: js/jco/webcrypto.js:2445-2453, -// the same decline). -// -// The interface is nonetheless PROVIDED (rather than left unlinked) so a -// component importing it instantiates: the conformance target declares -// `sha1-checked` in its missing-features list, and the suite's -// `!sha1-checked` decline case asserts the refusal actually works. - -import { errUnsupported } from "./errors.ts"; -import type { Digest } from "./digest.ts"; - -function unsupportedSha1Checked(): never { - errUnsupported("sha1-checked is not served by this implementation"); -} - -/** The `polymorph:webcrypto/sha1-checked@0.1.0` interface. */ -export const sha1Checked = { - makeRejectingDigest: (): Digest => unsupportedSha1Checked(), - makeMitigatingDigest: (): Digest => unsupportedSha1Checked(), -}; diff --git a/ports/webcrypto/src/signature.ts b/ports/webcrypto/src/signature.ts deleted file mode 100644 index 3b45f26..0000000 --- a/ports/webcrypto/src/signature.ts +++ /dev/null @@ -1,420 +0,0 @@ -// `polymorph:webcrypto/signature` (the algorithm-agnostic sign/verify -// resources) plus `ed25519-verify` / `ed25519-sign` — wit/webcrypto.wit -// `interface signature`, wit/ed25519.wit. -// -// The resource classes here are shared by every signature family: -// `ed25519-*` (this module), `ecdsa-*` (ecdsa.ts) and the two RSA pairs -// (rsaSignature.ts) all mint the same `verifying-key`/`signing-key`. Each -// key carries the ALGORITHM RECORD bound at its mint — never -// `CryptoKey.algorithm` — as the authority for the per-operation hash, the -// PSS salt length, the signature width, and the getters' answers: an -// engine that names its algorithms differently must not be able to switch -// a mandatory check off (reference: js/jco/webcrypto.js:4087-4110). - -import { - errAuthenticationFailed, - errInvalidKey, - errNotExtractable, - errUnsupported, - grantedUsages, - notPermitted, - platformCall, -} from "./errors.ts"; -import { asBufferSource, collectByteStream, unwrappedJwk } from "./util.ts"; -import { - importPlatformKey, - importPlatformKeyJwk, - jwkMaterial, - redactingInvalidKey, - requireStrictBase64url, - rfc8410SpkiKey, -} from "./platform.ts"; -import { b64urlDecode } from "./platform.ts"; -import { consumeUnwrapInput, type UnwrapInput, WrapInput } from "./wrapping.ts"; -import { errOther } from "./errors.ts"; -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; - -const subtle = globalThis.crypto.subtle; - -/** The mint-bound algorithm record every signature key carries (reference: webcrypto.js:3970). */ -export interface SignatureAlgorithm { - /** The WebCrypto registry name (`"Ed25519"`, `"ECDSA"`, `"RSA-PSS"`, …). */ - name: string; - /** The EC curve, for the getters; `undefined` off the EC families. */ - namedCurve: string | undefined; - /** The mint-bound digest; `undefined` for Ed25519 (RFC 8032 fixes it internally). */ - hash: string | undefined; - /** The RSA modulus length in bits; `undefined` elsewhere. */ - length?: number; - /** The fixed signature width in bytes this key accepts. */ - signatureLength: number; - /** RSA-PSS's mint-bound salt length in bytes. */ - saltLength?: number; - /** The uncompressed public-key length, where the raw form is admitted. */ - publicLength?: number; -} - -/** The Ed25519 record (reference: webcrypto.js:4042). */ -export const ED25519_ALGORITHM: SignatureAlgorithm = Object.freeze({ - name: "Ed25519", - namedCurve: undefined, - hash: undefined, - publicLength: 32, - signatureLength: 64, -}); - -/** - * The per-operation WebCrypto parameter for a key's mint binding - * (reference: webcrypto.js:4068): ECDSA passes its mint-bound hash, - * RSA-PSS its mint-bound salt length; every other family's binding rides - * the `CryptoKey`. - */ -// deno-lint-ignore no-explicit-any -function signParams(algorithm: SignatureAlgorithm): any { - if (algorithm.name === "ECDSA") return { name: "ECDSA", hash: algorithm.hash }; - if (algorithm.name === "RSA-PSS") return { name: "RSA-PSS", saltLength: algorithm.saltLength }; - return algorithm.name; -} - -// --- Ed25519 strict-validation predicates (the `ed25519-verify` WIT -// criterion: `verify_strict` semantics). Engines implement plain RFC 8032, -// which leaves acceptance of non-canonical and small-order inputs open, so -// this host enforces the pinned rejections itself: pure byte compares on -// public data, strictly monotone (they only add rejections in front of the -// engine). Reference: js/jco/webcrypto.js:4400-4470. - -function unhexFixed(hex: string): Uint8Array { - const pairs = hex.match(/../g); - if (pairs === null || pairs.length * 2 !== hex.length) { - throw new Error("malformed hex literal in the Ed25519 constant table"); - } - return Uint8Array.from(pairs, (byte) => parseInt(byte, 16)); -} - -/** The field prime p = 2^255 - 19, little-endian. */ -const ED25519_P = unhexFixed( - "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", -); -/** The group order L, little-endian. */ -const ED25519_L = unhexFixed( - "edd3f55c1a631258d69cf7a2def9de14" + "00".repeat(15) + "10", -); -/** The y-coordinates of the 8-torsion subgroup (reference: webcrypto.js:4409). */ -const ED25519_SMALL_ORDER_Y = [ - "0000000000000000000000000000000000000000000000000000000000000000", - "0100000000000000000000000000000000000000000000000000000000000000", - "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", - "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", - "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", -].map(unhexFixed); - -function ltLittleEndian(a: Uint8Array, b: Uint8Array): boolean { - for (let i = a.length - 1; i >= 0; i--) { - if (a[i] !== b[i]) return a[i] < b[i]; - } - return false; -} - -function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -/** Canonical (y < p) and not small-order — the predicate for `A` at import and `R` at verify. */ -function ed25519PointStrict(encoded: Uint8Array): boolean { - const y = encoded.slice(); - y[31] &= 0x7f; // mask the x sign bit - if (!ltLittleEndian(y, ED25519_P)) return false; - return !ED25519_SMALL_ORDER_Y.some((torsion) => bytesEqual(y, torsion)); -} - -/** `signature.verifying-key`: a public key, secret-free. */ -export class VerifyingKey { - #key: CryptoKey; - #algorithm: SignatureAlgorithm; - - constructor(key: CryptoKey, algorithm: SignatureAlgorithm) { - this.#key = key; - this.#algorithm = algorithm; - } - get cryptoKey(): CryptoKey { - return this.#key; - } - - /** - * Verify `sig` over the whole stream; a failure — including a malformed - * signature, which WebCrypto reports as a plain `false` — is - * `error.authentication-failed`. The stream is drained first (this host - * drains to completion rather than closing early). - */ - async verify(data: Stream, sig: Uint8Array): Promise { - const message = await collectByteStream(data); - // Each family's signature width is fixed at mint (Ed25519's 64-byte - // `R ‖ S`; ECDSA's P1363 `r ‖ s`; RSA's modulus-length octet string). - // Engines differ — Firefox zero-pads short halves — so the width is - // enforced here: a pure length check on public data (reference: - // webcrypto.js:4118-4128). - if (sig.length !== this.#algorithm.signatureLength) errAuthenticationFailed(); - if (this.#algorithm.name === "Ed25519") { - if (!ltLittleEndian(sig.subarray(32), ED25519_L)) errAuthenticationFailed(); - if (!ed25519PointStrict(sig.subarray(0, 32))) errAuthenticationFailed(); - } - const ok = await platformCall(`${this.#algorithm.name} verify`, () => - subtle.verify(signParams(this.#algorithm), this.#key, asBufferSource(sig), asBufferSource(message))); - if (!ok) errAuthenticationFailed(); - } - - algorithmName(): string { - return this.#algorithm.name; - } - algorithmCurve(): string | undefined { - return this.#algorithm.namedCurve; - } - algorithmHash(): string | undefined { - return this.#algorithm.hash; - } - algorithmLength(): number | undefined { - return this.#algorithm.length; - } - algorithmPublicExponent(): Uint8Array | undefined { - const e = (this.#key.algorithm as RsaHashedKeyAlgorithm).publicExponent; - return e === undefined ? undefined : new Uint8Array(e); - } - - /** The RSA family has no raw public form: the WIT pins `unsupported` (reference: webcrypto.js:4180). */ - async exportKeyRaw(): Promise { - if (this.#algorithm.name === "RSASSA-PKCS1-v1_5" || this.#algorithm.name === "RSA-PSS") { - errUnsupported("RSA public keys have no raw form"); - } - const raw = await platformCall("raw key export", () => subtle.exportKey("raw", this.#key)); - return new Uint8Array(raw); - } - async exportKeySpki(): Promise { - const spki = await platformCall("spki key export", () => subtle.exportKey("spki", this.#key)); - return new Uint8Array(spki); - } - /** Material members only, per the package-wide JWK contract (reference: webcrypto.js:4210). */ - async exportKeyJwk(): Promise { - const jwk = await platformCall("jwk key export", () => subtle.exportKey("jwk", this.#key)); - if (jwk.kty === "OKP") return JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }); - if (jwk.kty === "RSA") return JSON.stringify({ kty: jwk.kty, n: jwk.n, e: jwk.e }); - return JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }); - } -} - -/** `signature.signing-key`: a private key. */ -export class SigningKey { - #key: CryptoKey; - #algorithm: SignatureAlgorithm; - - constructor(key: CryptoKey, algorithm: SignatureAlgorithm) { - this.#key = key; - this.#algorithm = algorithm; - } - - async sign(data: Stream): Promise { - const message = await collectByteStream(data); - if (!this.canSign()) notPermitted("sign"); - const sig = await platformCall(`${this.#algorithm.name} sign`, () => - subtle.sign(signParams(this.#algorithm), this.#key, asBufferSource(message))); - return new Uint8Array(sig); - } - - algorithmName(): string { - return this.#algorithm.name; - } - algorithmCurve(): string | undefined { - return this.#algorithm.namedCurve; - } - algorithmHash(): string | undefined { - return this.#algorithm.hash; - } - algorithmLength(): number | undefined { - return this.#algorithm.length; - } - algorithmPublicExponent(): Uint8Array | undefined { - const e = (this.#key.algorithm as RsaHashedKeyAlgorithm).publicExponent; - return e === undefined ? undefined : new Uint8Array(e); - } - extractable(): boolean { - return this.#key.extractable; - } - canSign(): boolean { - return this.#key.usages.includes("sign"); - } - - /** Material members only; full-CRT for RSA (reference: webcrypto.js:4310). */ - async exportKeyJwk(): Promise { - if (!this.#key.extractable) errNotExtractable(); - const jwk = await platformCall("jwk key export", () => subtle.exportKey("jwk", this.#key)); - if (jwk.kty === "RSA") { - return JSON.stringify({ - kty: jwk.kty, - n: jwk.n, - e: jwk.e, - d: jwk.d, - p: jwk.p, - q: jwk.q, - dp: jwk.dp, - dq: jwk.dq, - qi: jwk.qi, - }); - } - return JSON.stringify( - jwk.kty === "OKP" - ? { kty: jwk.kty, crv: jwk.crv, x: jwk.x, d: jwk.d } - : { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, d: jwk.d }, - ); - } - async exportKeyPkcs8(): Promise { - if (!this.#key.extractable) errNotExtractable(); - const pkcs8 = await platformCall("pkcs8 key export", () => subtle.exportKey("pkcs8", this.#key)); - return new Uint8Array(pkcs8); - } - async toWrapInputJwk(): Promise { - return new WrapInput("jwk", new TextEncoder().encode(await this.exportKeyJwk())); - } - async toWrapInputPkcs8(): Promise { - return new WrapInput("pkcs8", await this.exportKeyPkcs8()); - } -} - -export interface SigningPolicy { - sign: boolean; - extractable: boolean; -} - -const signOptionsState = new WeakMap(); - -export function signingPolicyOf(o: SigningKeyOptions): SigningPolicy { - const p = signOptionsState.get(o); - if (p === undefined) errOther("signing-key-options minted by another provider"); - return p; -} - -/** `signature.signing-key-options`. */ -export class SigningKeyOptions { - constructor() { - signOptionsState.set(this, { sign: false, extractable: false }); - } - canSign(allowed: boolean): void { - signingPolicyOf(this).sign = allowed; - } - extractable(allowed: boolean): void { - signingPolicyOf(this).extractable = allowed; - } -} - -/** `sign` is the sole usage, so it must be granted (reference: webcrypto.js:4251). */ -export function requireSigningGrant(policy: SigningPolicy): void { - grantedUsages([["sign", policy.sign]]); -} - -/** - * The `polymorph:webcrypto/signature@0.1.0` interface: its resource classes. - * - * `SigningKeyOptions` is DEFINED by `signature` (webcrypto.wit:604,613) - * and merely `use`d by the `-sign` interfaces — a component importing both - * needs the class published under the defining interface too (found by the - * iroh endpoint exam). - */ -export const signature = { VerifyingKey, SigningKey, SigningKeyOptions }; - -/** - * The Ed25519 JWK `alg` policy: the two registered spellings, matched - * case-SENSITIVELY (RFC 8037 `EdDSA` and the CFRG registry's `Ed25519`). - * Checked host-side because Deno's import accepts a wrong-case `alg` - * where the WIT — and the suite's `probe/sig-public-format-imports` case - * — require `error.invalid-key`. - */ -function requireEd25519JwkAlg(jwk: Record): void { - const alg = jwk.alg; - if (alg === undefined) return; - if (alg !== "Ed25519" && alg !== "EdDSA") { - errInvalidKey(`Ed25519 JWK declares alg ${String(alg)}; the registered spellings are Ed25519 and EdDSA`); - } -} - -/** The `polymorph:webcrypto/ed25519-verify@0.1.0` interface. */ -export const ed25519Verify = { - importVerifyingKeyRaw: async (raw: Uint8Array): Promise => { - if (raw.length !== 32) errInvalidKey(`Ed25519 public keys are 32 bytes, got ${raw.length}`); - if (!ed25519PointStrict(raw)) errInvalidKey("non-canonical or small-order Ed25519 public key"); - const key = await importPlatformKey("Ed25519 public key", "raw", raw, "Ed25519", true, ["verify"]); - return new VerifyingKey(key, ED25519_ALGORITHM); - }, - importVerifyingKeySpki: async (spki: Uint8Array): Promise => { - const point = rfc8410SpkiKey(0x70, spki, "Ed25519"); - if (!ed25519PointStrict(point)) errInvalidKey("non-canonical or small-order Ed25519 public key"); - const key = await importPlatformKey("Ed25519 spki", "spki", spki, "Ed25519", true, ["verify"]); - return new VerifyingKey(key, ED25519_ALGORITHM); - }, - importVerifyingKeyJwk: async (jwkText: string): Promise => { - const jwk = jwkMaterial(jwkText); - requireEd25519JwkAlg(jwk); - requireStrictBase64url(jwk.x); - if (typeof jwk.x !== "string" || !ed25519PointStrict(b64urlDecode(jwk.x))) { - errInvalidKey("non-canonical or small-order Ed25519 public key"); - } - const key = await importPlatformKeyJwk("Ed25519 public JWK", jwk, "Ed25519", true, ["verify"]); - return new VerifyingKey(key, ED25519_ALGORITHM); - }, -}; - -/** The `polymorph:webcrypto/ed25519-sign@0.1.0` interface. */ -export const ed25519Sign = { - SigningKeyOptions, - generateKey: async (options: SigningKeyOptions): Promise<[SigningKey, VerifyingKey]> => { - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - // WebCrypto filters `usages` per key half from what was REQUESTED, so - // both are asked for here: a platform detail, not a WIT grant - // (`signing-key-options` carries no `can-verify` — `verifying-key` is - // secret-free and always usable). - const pair = await platformCall("Ed25519 key generation", () => - subtle.generateKey("Ed25519", policy.extractable, ["sign", "verify"])) as CryptoKeyPair; - return [new SigningKey(pair.privateKey, ED25519_ALGORITHM), new VerifyingKey(pair.publicKey, ED25519_ALGORITHM)]; - }, - importSigningKeyPkcs8: async (pkcs8: Uint8Array, options: SigningKeyOptions): Promise => { - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const key = await importPlatformKey("Ed25519 pkcs8", "pkcs8", pkcs8, "Ed25519", policy.extractable, ["sign"]); - return new SigningKey(key, ED25519_ALGORITHM); - }, - importSigningKeyJwk: async (jwkText: string, options: SigningKeyOptions): Promise => { - const policy = signingPolicyOf(options); - requireSigningGrant(policy); - const jwk = jwkMaterial(jwkText); - requireEd25519JwkAlg(jwk); - requireStrictBase64url(jwk.x); - requireStrictBase64url(jwk.d); - const key = await importPlatformKeyJwk( - "Ed25519 private JWK", - jwk, - "Ed25519", - policy.extractable, - ["sign"], - ); - if (key.type !== "private") errInvalidKey("OKP private JWK must carry `d` (base64url private key)"); - return new SigningKey(key, ED25519_ALGORITHM); - }, - unwrapSigningKeyPkcs8: (input: UnwrapInput, options: SigningKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - return redactingInvalidKey( - "unwrapped Ed25519 pkcs8", - () => ed25519Sign.importSigningKeyPkcs8(bytes, options), - ); - }, - unwrapSigningKeyJwk: (input: UnwrapInput, options: SigningKeyOptions): Promise => { - const { bytes } = consumeUnwrapInput(input); - requireSigningGrant(signingPolicyOf(options)); - const jwk = unwrappedJwk(bytes, "sig", ["sign"]); - return redactingInvalidKey( - "unwrapped Ed25519 private JWK", - () => ed25519Sign.importSigningKeyJwk(jwk, options), - ); - }, -}; diff --git a/ports/webcrypto/src/util.ts b/ports/webcrypto/src/util.ts deleted file mode 100644 index 8d3ebc0..0000000 --- a/ports/webcrypto/src/util.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Shared helpers for the `polymorph:webcrypto` port: byte-stream ingestion -// and JWK plumbing, ported from js/jco/webcrypto.js's `collectByteStream` -// and JWK-material helpers. -// -// Stream ingestion: a guest-provided `stream` arrives as this repo's -// `Stream` handle (contracts/embedder-api.md §"Streams and futures": -// `read(max): Promise>`, an empty chunk meaning end-of-stream) — -// not jco's async-iterable convention, so this collector is written -// directly against `Stream.read`, no jco-shape tolerance needed. - -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; -import { errInvalidKey } from "./errors.ts"; - -/** Read a guest `stream` to completion, copying chunks into one buffer. */ -export async function collectByteStream(data: Stream): Promise { - const chunks: Uint8Array[] = []; - let total = 0; - // deno-lint-ignore no-constant-condition - while (true) { - const chunk = await data.read(65536); - const len = (chunk as { length: number }).length; - if (len === 0) break; - const bytes = chunk instanceof Uint8Array ? chunk : Uint8Array.from(chunk as number[]); - chunks.push(bytes); - total += bytes.length; - } - const out = new Uint8Array(total); - let offset = 0; - for (const c of chunks) { - out.set(c, offset); - offset += c.length; - } - return out; -} - -/** Narrow a lifted `list` to the `BufferSource` WebCrypto takes (always a fresh copy per the value-mapping table, so this is a type-level cast only). */ -export function asBufferSource(bytes: Uint8Array): Uint8Array { - return bytes as Uint8Array; -} - -const utf8Encoder = new TextEncoder(); -const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); - -export function utf8Encode(s: string): Uint8Array { - return utf8Encoder.encode(s); -} - -export function utf8Decode(bytes: Uint8Array): string { - try { - return utf8Decoder.decode(bytes); - } catch { - errInvalidKey("unwrapped material is not valid UTF-8"); - } -} - -/** - * Parse an unwrap-input's bytes as JWK text and validate the `use`/ - * `key_ops` members in the caller's stead (the unwrap-path JWK contract; - * reference: js/jco/webcrypto.js `unwrappedJwk`, lines 1238-1280). Both - * members are stripped from the result, as on the import path. - */ -export function unwrappedJwk(bytes: Uint8Array, family: "enc" | "sig", grantedOps: string[]): string { - const text = utf8Decode(bytes); - let jwk: unknown; - try { - jwk = JSON.parse(text); - } catch { - return errInvalidKey("unwrapped JWK is not valid JSON"); - } - if (typeof jwk !== "object" || jwk === null || Array.isArray(jwk)) { - errInvalidKey("unwrapped JWK must be a JSON object"); - } - const { use, key_ops, ...material } = jwk as Record; - if (use !== undefined && use !== family) { - errInvalidKey("unwrapped JWK `use` does not match the key's family"); - } - if (key_ops !== undefined) { - if (!Array.isArray(key_ops) || !grantedOps.every((op) => (key_ops as unknown[]).includes(op))) { - errInvalidKey("unwrapped JWK `key_ops` does not cover the granted usages"); - } - } - return JSON.stringify(material); -} diff --git a/ports/webcrypto/src/wrapping.ts b/ports/webcrypto/src/wrapping.ts deleted file mode 100644 index 23028af..0000000 --- a/ports/webcrypto/src/wrapping.ts +++ /dev/null @@ -1,57 +0,0 @@ -// `polymorph:webcrypto/wrapping` — wit/wrapping.wit `interface wrapping`. -// -// Provider-held intermediates for key wrapping: `wrap-input` (serialized key -// material awaiting encryption) and `unwrap-input` (decrypted material -// awaiting a typed mint). Both are per-instance and consumed exactly once — -// consumption removes the state before anything else runs, on failure as on -// success (the WIT contract), mirroring the reference's `consumeWrapInput`/ -// `consumeUnwrapInput` (js/jco/webcrypto.js:1194-1214). -// -// Per contracts/embedder-api.md §"Resources", host-implemented resources are -// plain classes; the runtime (not this port) owns handle identity. State -// lives in module-private WeakMaps rather than public fields so the bytes -// never become guest-readable through the class shape. - -import { errOther } from "./errors.ts"; - -export type WrapFormat = "raw" | "jwk" | "pkcs8"; - -const wrapState = new WeakMap(); -const unwrapState = new WeakMap(); - -/** `wrapping.wrap-input`: serialized key material awaiting a wrap. */ -export class WrapInput { - constructor(format: WrapFormat, bytes: Uint8Array) { - wrapState.set(this, { format, bytes }); - } -} - -/** `wrapping.unwrap-input`: decrypted key material awaiting a typed mint. */ -export class UnwrapInput { - constructor(bytes: Uint8Array) { - unwrapState.set(this, { bytes }); - } -} - -/** Consume a `wrap-input`; a miss means already-consumed or foreign. */ -export function consumeWrapInput(input: WrapInput): { format: WrapFormat; bytes: Uint8Array } { - const state = wrapState.get(input); - wrapState.delete(input); - if (state === undefined) { - errOther("wrap-input already consumed or minted by another provider"); - } - return state; -} - -/** Consume an `unwrap-input`; see `consumeWrapInput`. */ -export function consumeUnwrapInput(input: UnwrapInput): { bytes: Uint8Array } { - const state = unwrapState.get(input); - unwrapState.delete(input); - if (state === undefined) { - errOther("unwrap-input already consumed or minted by another provider"); - } - return state; -} - -/** The `polymorph:webcrypto/wrapping@0.1.0` interface: its resource classes. */ -export const wrapping = { WrapInput, UnwrapInput }; diff --git a/ports/webcrypto/tests/aead_test.ts b/ports/webcrypto/tests/aead_test.ts deleted file mode 100644 index 256c6b3..0000000 --- a/ports/webcrypto/tests/aead_test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Unit tests: `polymorph:webcrypto/aead` + `aes-gcm` (NIST SP 800-38D). -// -// KAT source: conformance/vectors/aes_gcm_test.json (Wycheproof-format, -// group 0: 128-bit key / 96-bit IV / 128-bit tag — tcId 1). - -import { assertEq, assertRejects } from "./asserts.ts"; -import { AeadKeyOptions, aesGcm } from "../src/mod.ts"; -import { arrayStream } from "./testStream.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; - -function hexToBytes(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - return out; -} -function hex(bytes: Uint8Array): string { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -function fullOptions(): AeadKeyOptions { - const o = new AeadKeyOptions(); - o.canSeal(true); - o.canOpen(true); - return o; -} - -Deno.test("aes-gcm: seal/open round-trip (aes256)", async () => { - const key = await aesGcm.generateKey("aes256", fullOptions()); - const nonce = crypto.getRandomValues(new Uint8Array(12)); - const aad = new TextEncoder().encode("associated data"); - const plaintext = new TextEncoder().encode("iroh handshake payload"); - const sealed = await key.seal(nonce, aad, undefined, arrayStream(plaintext)); - assertEq(sealed.length, plaintext.length + 16); // default 16-byte tag - const opened = await key.open(nonce, aad, undefined, arrayStream(sealed)); - assertEq(hex(opened), hex(plaintext)); - assertEq(key.algorithmName(), "AES-GCM"); - assertEq(key.nonceSize(), 12); - assertEq(key.tagSize(), 16); -}); - -Deno.test("aes-gcm: KAT against conformance/vectors/aes_gcm_test.json group 0 tcId 1 (128-bit key)", async () => { - let raw: string; - try { - raw = await Deno.readTextFile(`${VECTORS_DIR}/aes_gcm_test.json`); - } catch { - console.log(" (skip: polymorph-webcrypto vectors tree not present)"); - return; - } - const doc = JSON.parse(raw); - const tc = doc.testGroups[0].tests.find((t: { tcId: number }) => t.tcId === 1); - const key = await aesGcm.importKeyRaw("aes128", hexToBytes(tc.key), fullOptions()); - const sealed = await key.seal(hexToBytes(tc.iv), hexToBytes(tc.aad), undefined, arrayStream(hexToBytes(tc.msg))); - assertEq(hex(sealed), (tc.ct + tc.tag).toLowerCase()); -}); - -Deno.test("aes-gcm: open with a tampered ciphertext fails error.authentication-failed", async () => { - const key = await aesGcm.generateKey("aes256", fullOptions()); - const nonce = crypto.getRandomValues(new Uint8Array(12)); - const sealed = await key.seal(nonce, new Uint8Array(0), undefined, arrayStream(new TextEncoder().encode("data"))); - sealed[0] ^= 0xff; - const err = await assertRejects( - () => key.open(nonce, new Uint8Array(0), undefined, arrayStream(sealed)), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "authentication-failed"); -}); - -Deno.test("aes-gcm: seal on an open-only key fails error.not-permitted", async () => { - const opts = new AeadKeyOptions(); - opts.canOpen(true); // seal NOT granted - const key = await aesGcm.generateKey("aes256", opts); - const err = await assertRejects( - () => key.seal(new Uint8Array(12), new Uint8Array(0), undefined, arrayStream(new Uint8Array(4))), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "not-permitted"); -}); - -Deno.test("aes-gcm: seal with an out-of-window nonce fails error.invalid-nonce", async () => { - const key = await aesGcm.generateKey("aes256", fullOptions()); - const err = await assertRejects( - () => key.seal(new Uint8Array(4), new Uint8Array(0), undefined, arrayStream(new Uint8Array(4))), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "invalid-nonce"); -}); - -Deno.test("aes-gcm: aes192 is declined with error.unsupported (WIT portability ruling, not a Deno-specific gap)", async () => { - const err = await assertRejects( - () => aesGcm.generateKey("aes192", fullOptions()), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "unsupported"); -}); diff --git a/ports/webcrypto/tests/asserts.ts b/ports/webcrypto/tests/asserts.ts deleted file mode 100644 index dd360b8..0000000 --- a/ports/webcrypto/tests/asserts.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Minimal, dependency-free assertion helpers for wasi-shims tests. - -export function assertEq(actual: T, expected: T, msg?: string): void { - const ok = Object.is(actual, expected) || - (typeof actual === "bigint" && typeof expected === "bigint" && actual === expected); - if (!ok) { - throw new Error( - `${msg ?? "assertEq failed"}: expected ${describe(expected)}, got ${describe(actual)}`, - ); - } -} - -export function assertTrue(cond: boolean, msg?: string): void { - if (!cond) throw new Error(msg ?? "assertTrue failed"); -} - -export async function assertRejects( - f: () => unknown | Promise, - msg?: string, -): Promise { - try { - await f(); - } catch (e) { - return e; - } - throw new Error(msg ?? "expected a throw/rejection, got none"); -} - -export function assertThrows(f: () => unknown, msg?: string): unknown { - try { - f(); - } catch (e) { - return e; - } - throw new Error(msg ?? "expected a throw, got none"); -} - -function describe(v: unknown): string { - if (typeof v === "bigint") return `${v}n`; - if (v instanceof Uint8Array) return `Uint8Array[${v.join(",")}]`; - try { - return JSON.stringify(v); - } catch { - return String(v); - } -} diff --git a/ports/webcrypto/tests/digest_test.ts b/ports/webcrypto/tests/digest_test.ts deleted file mode 100644 index a169e8e..0000000 --- a/ports/webcrypto/tests/digest_test.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Unit tests: `polymorph:webcrypto/digest` + `sha2`. -// -// Known-answer digest: NIST FIPS 180-4 example message "abc" (the standard -// SHA-2 KAT every implementation cites); SHA-256 hash cited by its FIPS -// 180-4 test-vector name, not reproduced as a literal blob here beyond the -// standard published digest. - -import { assertEq, assertRejects, assertThrows } from "./asserts.ts"; -import { sha2 } from "../src/mod.ts"; -import { arrayStream } from "./testStream.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -function hex(bytes: Uint8Array): string { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -Deno.test("sha2: makeDigest(sha256).compute('abc') matches FIPS 180-4 KAT", async () => { - const d = sha2.makeDigest("sha256"); - const out = await d.compute(arrayStream(new TextEncoder().encode("abc"))); - assertEq( - hex(out), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", - ); - assertEq(d.algorithmName(), "SHA-256"); -}); - -Deno.test("sha2: makeDigest(sha384/sha512) compute over empty stream", async () => { - for (const [variant, hashLen] of [["sha384", 48], ["sha512", 64]] as const) { - const d = sha2.makeDigest(variant); - const out = await d.compute(arrayStream(new Uint8Array(0))); - assertEq(out.length, hashLen); - } -}); - -Deno.test("sha2: sha224/sha512-224/sha512-256 decline with error.unsupported (WIT-mandated, not a Deno gap)", () => { - for (const variant of ["sha224", "sha512-224", "sha512-256"]) { - const err = assertThrows(() => sha2.makeDigest(variant)) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "unsupported"); - } -}); - diff --git a/ports/webcrypto/tests/families_test.ts b/ports/webcrypto/tests/families_test.ts deleted file mode 100644 index ebef83a..0000000 --- a/ports/webcrypto/tests/families_test.ts +++ /dev/null @@ -1,281 +0,0 @@ -// Unit KATs for the families added in the issue-#3 completion pass: -// cipher/aes-cbc + aes-ctr, key-wrap/aes-kw, pbkdf2, ecdh, ecdsa, -// the RSA signature verifiers, RSA-OAEP, and the sha1-checked decline. -// -// Every case names its vector by FILE + tcId (Wycheproof format, in the -// consumer's conformance/vectors tree) and asserts one of two things: a -// published-vector agreement (the positive), or that a tampered / -// upstream-invalid input is refused with the WIT taxonomy's verdict for -// that condition (the negative). No key material is inlined here; the -// vectors are read from disk, and the suite skips when that tree is -// absent — the same guard the other KAT files use. - -import { assertEq, assertRejects } from "./asserts.ts"; -import { - aesCbc, - aesCtr, - aesKw, - CipherKeyOptions, - DecryptionKeyOptions, - DeriveOptions, - ecdh, - ecdsaSign, - ecdsaVerify, - KwKeyOptions, - pbkdf2, - pbkdf2Sha2, - rsaOaepDecrypt, - rsaPssVerify, - rsassaPkcs1V15Verify, - sha1Checked, - SigningKeyOptions, - AgreementKeyOptions, -} from "../src/mod.ts"; -import { arrayStream } from "./testStream.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; - -function hexToBytes(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - return out; -} -function hex(bytes: Uint8Array): string { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} -// deno-lint-ignore no-explicit-any -async function vectors(file: string): Promise { - try { - return JSON.parse(await Deno.readTextFile(`${VECTORS_DIR}/${file}`)); - } catch { - console.log(" (skip: polymorph-webcrypto vectors tree not present)"); - return undefined; - } -} -// deno-lint-ignore no-explicit-any -function tc(doc: any, tcId: number, group = 0): any { - const found = doc.testGroups[group].tests.find((t: { tcId: number }) => t.tcId === tcId); - if (found === undefined) throw new Error(`tcId ${tcId} not in group ${group}`); - return found; -} -function errKind(err: unknown): string { - return ((err as ComponentException).payload as { kind: string }).kind; -} -function cipherOptions(): CipherKeyOptions { - const o = new CipherKeyOptions(); - o.canEncrypt(true); - o.canDecrypt(true); - return o; -} -async function collect(chunks: Uint8Array[]): Promise { - return chunks[0] ?? new Uint8Array(0); -} - -Deno.test("aes-cbc: KAT against aes_cbc_pkcs5_test.json tcId 2 (PKCS#5-padded, 128-bit key)", async () => { - const doc = await vectors("aes_cbc_pkcs5_test.json"); - if (doc === undefined) return; - const t = tc(doc, 2); - const key = await aesCbc.importKeyRaw("aes128", hexToBytes(t.key), cipherOptions()); - const out = await collect(await key.encrypt(hexToBytes(t.iv), undefined, arrayStream(hexToBytes(t.msg)))); - assertEq(hex(out), t.ct.toLowerCase()); - assertEq(key.algorithmName(), "AES-CBC"); - assertEq(key.ivSize(), 16); -}); - -Deno.test("aes-cbc: a tampered ciphertext fails the WIT's uniform error.other (no padding verdict)", async () => { - const doc = await vectors("aes_cbc_pkcs5_test.json"); - if (doc === undefined) return; - const t = tc(doc, 2); - const key = await aesCbc.importKeyRaw("aes128", hexToBytes(t.key), cipherOptions()); - const ct = hexToBytes(t.ct); - ct[ct.length - 1] ^= 0xff; // corrupt the final block: bad padding on decrypt - const err = await assertRejects(() => key.decrypt(hexToBytes(t.iv), undefined, arrayStream(ct))); - assertEq(errKind(err), "other"); -}); - -Deno.test("aes-ctr: a counter length is required, and AES-CBC refuses one (error.invalid-nonce both ways)", async () => { - const ctr = await aesCtr.generateKey("aes256", cipherOptions()); - const missing = await assertRejects(() => - ctr.encrypt(new Uint8Array(16), undefined, arrayStream(new Uint8Array(4))) - ); - assertEq(errKind(missing), "invalid-nonce"); - const cbc = await aesCbc.generateKey("aes256", cipherOptions()); - const extra = await assertRejects(() => cbc.encrypt(new Uint8Array(16), 64, arrayStream(new Uint8Array(4)))); - assertEq(errKind(extra), "invalid-nonce"); -}); - -Deno.test("aes-ctr: encrypt/decrypt round-trip at a 128-bit counter", async () => { - const key = await aesCtr.generateKey("aes256", cipherOptions()); - const iv = crypto.getRandomValues(new Uint8Array(16)); - const message = new TextEncoder().encode("counter-mode payload"); - const ct = await collect(await key.encrypt(iv, 128, arrayStream(message))); - const pt = await collect(await key.decrypt(iv, 128, arrayStream(ct))); - assertEq(hex(pt), hex(message)); -}); - -Deno.test("aes-kw: KAT against aes_wrap_test.json tcId 3 (RFC 3394 wrap of 16-byte material)", async () => { - const doc = await vectors("aes_wrap_test.json"); - if (doc === undefined) return; - const group = doc.testGroups.findIndex((g: { keySize: number }) => g.keySize === 128); - const t = doc.testGroups[group].tests.find((x: { result: string; msg: string }) => - x.result === "valid" && x.msg.length / 2 >= 16 - ); - const o = new KwKeyOptions(); - o.canWrap(true); - o.canUnwrap(true); - const key = await aesKw.importKeyRaw("aes128", hexToBytes(doc.testGroups[group].tests[0].key), o); - // The WIT's wrap path takes a `wrap-input`; the KAT compares against the - // vector's own wrapped form through the round trip instead, since - // `wrap-input`s are minted only by exporting keys. - const unwrapped = await key.unwrap(hexToBytes(t.ct)); - assertEq(typeof unwrapped, "object"); - assertEq(key.algorithmName(), "AES-KW"); -}); - -Deno.test("aes-kw: a tampered wrapped blob fails error.authentication-failed (integrity is the whole point)", async () => { - const doc = await vectors("aes_wrap_test.json"); - if (doc === undefined) return; - const group = doc.testGroups.findIndex((g: { keySize: number }) => g.keySize === 128); - const t = doc.testGroups[group].tests.find((x: { result: string; msg: string }) => - x.result === "valid" && x.msg.length / 2 >= 16 - ); - const o = new KwKeyOptions(); - o.canUnwrap(true); - const key = await aesKw.importKeyRaw("aes128", hexToBytes(doc.testGroups[group].tests[0].key), o); - const wrapped = hexToBytes(t.ct); - wrapped[0] ^= 0xff; - const err = await assertRejects(() => key.unwrap(wrapped)); - assertEq(errKind(err), "authentication-failed"); -}); - -Deno.test("pbkdf2-sha2: KAT against pbkdf2_hmacsha256_test.json tcId 1 (RFC 7914)", async () => { - const doc = await vectors("pbkdf2_hmacsha256_test.json"); - if (doc === undefined) return; - const t = tc(doc, 1); - const o = new DeriveOptions(); - o.canDeriveBits(true); - const password = await pbkdf2.importPassword(hexToBytes(t.password), o); - const input = await pbkdf2Sha2.prepare("sha256", password, hexToBytes(t.salt), t.iterationCount); - const dk = await input.deriveBits(t.dkLen * 8); - assertEq(hex(dk), t.dk.toLowerCase()); -}); - -Deno.test("pbkdf2-sha2: a zero iteration count fails error.other at prepare, before anything can mint", async () => { - const o = new DeriveOptions(); - o.canDeriveBits(true); - const password = await pbkdf2.importPassword(new Uint8Array([1, 2, 3]), o); - const err = await assertRejects(() => pbkdf2Sha2.prepare("sha256", password, new Uint8Array(8), 0)); - assertEq(errKind(err), "other"); -}); - -Deno.test("ecdh: KAT against ecdh_secp256r1_webcrypto_test.json tcId 1 (agreed secret matches the vector)", async () => { - const doc = await vectors("ecdh_secp256r1_webcrypto_test.json"); - if (doc === undefined) return; - const t = tc(doc, 1); - const o = new AgreementKeyOptions(); - o.canDeriveBits(true); - const secret = await ecdh.importSecretKeyJwk("p256", JSON.stringify(t.private), o); - const peer = await ecdh.importPublicKeyJwk("p256", JSON.stringify(t.public)); - const input = await secret.agree(peer); - const bits = await input.deriveBits(undefined); - assertEq(hex(bits), t.shared.toLowerCase()); -}); - -Deno.test("ecdh: an off-curve peer point is refused (error.invalid-key; ecdh_secp256r1_ecpoint_test.json tcId 332, InvalidCurveAttack)", async () => { - const doc = await vectors("ecdh_secp256r1_ecpoint_test.json"); - if (doc === undefined) return; - const t = tc(doc, 332); - assertEq(t.result, "invalid"); - const err = await assertRejects(() => ecdh.importPublicKeyRaw("p256", hexToBytes(t.public))); - assertEq(errKind(err), "invalid-key"); -}); - -Deno.test("ecdsa-verify: KAT against ecdsa_secp256r1_sha256_p1363_test.json tcId 2 (valid P1363 signature)", async () => { - const doc = await vectors("ecdsa_secp256r1_sha256_p1363_test.json"); - if (doc === undefined) return; - const g = doc.testGroups[0]; - const t = g.tests.find((x: { result: string }) => x.result === "valid"); - const key = await ecdsaVerify.importVerifyingKeyJwk("p256-sha256", JSON.stringify(g.publicKeyJwk)); - await key.verify(arrayStream(hexToBytes(t.msg)), hexToBytes(t.sig)); - assertEq(key.algorithmCurve(), "P-256"); -}); - -Deno.test("ecdsa-verify: an upstream-invalid signature fails error.authentication-failed", async () => { - const doc = await vectors("ecdsa_secp256r1_sha256_p1363_test.json"); - if (doc === undefined) return; - const g = doc.testGroups[0]; - const t = g.tests.find((x: { result: string }) => x.result === "invalid"); - const key = await ecdsaVerify.importVerifyingKeyJwk("p256-sha256", JSON.stringify(g.publicKeyJwk)); - const err = await assertRejects(() => key.verify(arrayStream(hexToBytes(t.msg)), hexToBytes(t.sig))); - assertEq(errKind(err), "authentication-failed"); -}); - -Deno.test("ecdsa-sign: generate -> sign -> verify round-trip (P-384/SHA-384)", async () => { - const o = new SigningKeyOptions(); - o.canSign(true); - const [sk, vk] = await ecdsaSign.generateKey("p384-sha384", o); - const message = new TextEncoder().encode("deltic ecdsa round trip"); - const sig = await sk.sign(arrayStream(message)); - assertEq(sig.length, 96); - await vk.verify(arrayStream(message), sig); -}); - -Deno.test("rsassa-pkcs1-v15-verify: KAT against rsa_signature_2048_sha256_test.json (valid + upstream-invalid)", async () => { - const doc = await vectors("rsa_signature_2048_sha256_test.json"); - if (doc === undefined) return; - const g = doc.testGroups[0]; - const key = await rsassaPkcs1V15Verify.importVerifyingKeyJwk("sha256", JSON.stringify(g.keyJwk ?? g.publicKeyJwk)); - const ok = g.tests.find((x: { result: string }) => x.result === "valid"); - await key.verify(arrayStream(hexToBytes(ok.msg)), hexToBytes(ok.sig)); - const bad = g.tests.find((x: { result: string }) => x.result === "invalid"); - const err = await assertRejects(() => key.verify(arrayStream(hexToBytes(bad.msg)), hexToBytes(bad.sig))); - assertEq(errKind(err), "authentication-failed"); -}); - -Deno.test("rsa-pss-verify: KAT against rsa_pss_2048_sha256_mgf1_32_test.json (salt length bound at mint)", async () => { - const doc = await vectors("rsa_pss_2048_sha256_mgf1_32_test.json"); - if (doc === undefined) return; - const g = doc.testGroups[0]; - const key = await rsaPssVerify.importVerifyingKeyJwk("sha256", g.sLen, JSON.stringify(g.publicKeyJwk)); - const ok = g.tests.find((x: { result: string }) => x.result === "valid"); - await key.verify(arrayStream(hexToBytes(ok.msg)), hexToBytes(ok.sig)); - // A signature made under a different salt length must not verify: the - // key's salt length is mint-bound (`import-verifying-key-jwk`'s contract). - const other = await rsaPssVerify.importVerifyingKeyJwk("sha256", 0, JSON.stringify(g.publicKeyJwk)); - const err = await assertRejects(() => other.verify(arrayStream(hexToBytes(ok.msg)), hexToBytes(ok.sig))); - assertEq(errKind(err), "authentication-failed"); -}); - -Deno.test("rsa-oaep: KAT against rsa_oaep_2048_sha256_mgf1sha256_test.json tcId 1 (valid) and tcId 32 (truncated ciphertext)", async () => { - const doc = await vectors("rsa_oaep_2048_sha256_mgf1sha256_test.json"); - if (doc === undefined) return; - const g = doc.testGroups[0]; - const o = new DecryptionKeyOptions(); - o.canDecrypt(true); - const jwk = { ...g.privateKeyJwk }; - delete (jwk as { kid?: string }).kid; - const key = await rsaOaepDecrypt.importDecryptionKeyJwk("sha256", JSON.stringify(jwk), o); - const ok = tc(doc, 1); - const pt = await key.decrypt(ok.label.length > 0 ? hexToBytes(ok.label) : undefined, hexToBytes(ok.ct)); - assertEq(hex(pt), ok.msg.toLowerCase()); - const bad = tc(doc, 32); - assertEq(bad.result, "invalid"); - const err = await assertRejects(() => - key.decrypt(bad.label.length > 0 ? hexToBytes(bad.label) : undefined, hexToBytes(bad.ct)) - ); - // RFC 8017's single verdict: every decryption failure is detail-free. - assertEq(errKind(err), "authentication-failed"); -}); - -Deno.test("sha1-checked: both postures decline with error.unsupported (no platform carries sha1dc)", () => { - for (const mint of [sha1Checked.makeRejectingDigest, sha1Checked.makeMitigatingDigest]) { - let caught: unknown; - try { - mint(); - } catch (e) { - caught = e; - } - assertEq(errKind(caught), "unsupported"); - } -}); diff --git a/ports/webcrypto/tests/hkdf_test.ts b/ports/webcrypto/tests/hkdf_test.ts deleted file mode 100644 index b12b3d4..0000000 --- a/ports/webcrypto/tests/hkdf_test.ts +++ /dev/null @@ -1,63 +0,0 @@ -// Unit tests: `polymorph:webcrypto/hkdf` + `hkdf-sha2` (RFC 5869). -// -// KAT source: conformance/vectors/hkdf_sha256_test.json tcId 1 — RFC 5869 -// Appendix A.1 Test Case 1 (the construction's own reference vector). - -import { assertEq, assertRejects } from "./asserts.ts"; -import { DeriveOptions, hkdf, hkdfSha2 } from "../src/mod.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; - -function hexToBytes(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - return out; -} -function hex(bytes: Uint8Array): string { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -function deriveOptions(): DeriveOptions { - const o = new DeriveOptions(); - o.canDeriveBits(true); - o.canDeriveKey(true); - return o; -} - -Deno.test("hkdf-sha2: RFC 5869 Test Case 1 KAT (conformance/vectors/hkdf_sha256_test.json tcId 1)", async () => { - let raw: string; - try { - raw = await Deno.readTextFile(`${VECTORS_DIR}/hkdf_sha256_test.json`); - } catch { - console.log(" (skip: polymorph-webcrypto vectors tree not present)"); - return; - } - const doc = JSON.parse(raw); - const tc = doc.testGroups[0].tests.find((t: { tcId: number }) => t.tcId === 1); - const ikm = await hkdf.importIkm(hexToBytes(tc.ikm), deriveOptions()); - const input = await hkdfSha2.prepare("sha256", ikm, hexToBytes(tc.salt), hexToBytes(tc.info)); - const okm = await input.deriveBits(tc.size * 8); - assertEq(hex(okm), tc.okm.toLowerCase()); -}); - -Deno.test("hkdf: import-ikm accepts empty material (RFC 5869 permits it)", async () => { - const ikm = await hkdf.importIkm(new Uint8Array(0), deriveOptions()); - const input = await hkdfSha2.prepare("sha256", ikm, new Uint8Array(0), new Uint8Array(0)); - const okm = await input.deriveBits(32); - assertEq(okm.length, 4); -}); - -Deno.test("hkdf: import-ikm with a grantless options resource fails error.not-permitted", async () => { - const err = await assertRejects( - () => hkdf.importIkm(new Uint8Array(16), new DeriveOptions()), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "not-permitted"); -}); - -Deno.test("hkdf-sha2: derive-bits(none) on a KDF input fails error.other (no natural output length)", async () => { - const ikm = await hkdf.importIkm(new Uint8Array(16).fill(7), deriveOptions()); - const input = await hkdfSha2.prepare("sha256", ikm, new Uint8Array(0), new Uint8Array(0)); - const err = await assertRejects(() => input.deriveBits(undefined)) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "other"); -}); diff --git a/ports/webcrypto/tests/import_surface_test.ts b/ports/webcrypto/tests/import_surface_test.ts deleted file mode 100644 index ee74a9b..0000000 --- a/ports/webcrypto/tests/import_surface_test.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Gate: BOTH of polymorph-webcrypto's conformance suites must be fully -// linkable by this port — translate-only, no instantiation. -// -// `analyzeImports` reports the top-level import keys a suite needs that a -// given imports record does not resolve (ct-runner/src/import-analysis.ts, -// the same version-canonical resolution `instantiate` uses). A non-empty -// `missing` list is exactly the failure this port exists to close, so it -// is asserted to be empty for the shared suite AND the signing suite — -// including `sha1-checked`, which is PROVIDED as a fail-closed interface -// (see src/sha1Checked.ts) rather than left unlinked. -// -// Skipped when the artifacts are absent (the consumer tree or the -// translator shim), like the vector-backed KATs in this directory. - -import { Translator } from "../../../runtime/src/shim/mod.ts"; -import { analyzeImports } from "../../../ct-runner/src/mod.ts"; -import { wasiShims } from "../../../wasi-shims/src/mod.ts"; -import { webcryptoImports } from "../src/mod.ts"; -import { assertEq } from "./asserts.ts"; - -const CE_ROOT = new URL("../../../", import.meta.url).pathname; -const SHIM = `${CE_ROOT}target/wasm32-unknown-unknown/release/translator_shim.wasm`; -const SUITES = "/home/lmartin/p/polymorph/polymorph-webcrypto/target/wasm32-wasip2/release"; - -async function readIfPresent(path: string): Promise { - try { - return await Deno.readFile(path); - } catch { - return undefined; - } -} - -async function missingLeaves(suite: string): Promise { - const shim = await readIfPresent(SHIM); - const componentBytes = await readIfPresent(`${SUITES}/${suite}.wasm`); - if (shim === undefined || componentBytes === undefined) return undefined; - const translator = await Translator.create(shim); - const { plan } = translator.translate(componentBytes); - const analysis = analyzeImports(plan, { ...wasiShims(), ...webcryptoImports() }); - return analysis.missing; -} - -for (const suite of ["conformance_guest_ct", "conformance_signing_guest_ct"]) { - Deno.test(`import surface: ${suite} has zero unresolved leaves under wasiShims() + webcryptoImports()`, async () => { - const missing = await missingLeaves(suite); - if (missing === undefined) { - console.log(" (skip: the suite artifact or the translator shim is not built)"); - return; - } - assertEq(missing.join(", "), ""); - }); -} diff --git a/ports/webcrypto/tests/integration_exec_model_test.ts b/ports/webcrypto/tests/integration_exec_model_test.ts deleted file mode 100644 index 41f22ea..0000000 --- a/ports/webcrypto/tests/integration_exec_model_test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Integration gate (mission dispatch item 4): the iroh exec-model probe -// sequence, driven end-to-end via `instantiate(artifacts, { ...wasiShims(), -// ...webcryptoImports() })` — the REAL `polymorph:webcrypto` port replacing -// wasi-shims's test-local `webcryptoFixture()` fixture -// (wasi-shims/tests/integration_exec_model_test.ts:31-86, which this test -// otherwise mirrors probe-for-probe). -// -// Skip-if-absent: the artifact is a real polymorph build product, not -// checked in here. - -import { assertEq, assertTrue } from "./asserts.ts"; -import { Translator } from "../../../runtime/src/shim/mod.ts"; -import { instantiate, Stream } from "../../../runtime/src/embedder/mod.ts"; -import { wasiShims } from "../../../wasi-shims/src/mod.ts"; -import { webcryptoImports } from "../src/mod.ts"; - -const ARTIFACT = - "/home/lmartin/p/polymorph/polymorph-iroh/target/wasm32-wasip2/release/iroh_exec_model_guest.wasm"; -const SHIM_WASM = new URL( - "../../../target/wasm32-unknown-unknown/release/translator_shim.wasm", - import.meta.url, -); - -async function readIfPresent(path: string | URL): Promise { - try { - return await Deno.readFile(path); - } catch { - return null; - } -} - -Deno.test({ - name: "integration: iroh_exec_model_guest.wasm probe sequence via wasiShims() + webcryptoImports() (real port)", - ignore: (await readIfPresent(ARTIFACT)) === null || - (await readIfPresent(SHIM_WASM)) === null, - fn: async () => { - const bytes = (await readIfPresent(ARTIFACT))!; - const shimBytes = (await readIfPresent(SHIM_WASM))!; - const translator = await Translator.create(shimBytes); - const { plan, adapters } = translator.translate(bytes); - - const shims = wasiShims(); - const instance = await instantiate( - { plan, componentBytes: bytes, adapters }, - { ...shims, ...webcryptoImports() }, - ); - - const probeName = plan.exports[0].name; - // deno-lint-ignore no-explicit-any - const probe = instance.exports[probeName] as any; - assertTrue(typeof probe.blockonInSpawn === "function", "probe surfaced"); - - // Probe 1 — block_on inside a spawned task, export still live. - const desc1 = await probe.blockonInSpawn(); - assertTrue(typeof desc1 === "string", "blockon-in-spawn resolved a string"); - - // Probes 2+3 — the jco#11 shape: start-pump MUST return while its - // detached pump task still holds an in-flight wait-for, and poll-pump - // MUST then run to completion. - const t0 = performance.now(); - await probe.startPump(); - const startElapsed = performance.now() - t0; - assertTrue( - startElapsed < 50, - `start-pump returned before its 50ms wait-for completed (${startElapsed}ms) ` + - `— the detached task is still in flight`, - ); - const desc3 = await probe.pollPump(); - assertTrue( - typeof desc3 === "string", - "poll-pump ran to completion after a detached task was left parked " + - "(lann/jco#11 / polymorph-iroh#10)", - ); - - // Probe 4a — exported stream, read to completion. - const s1 = await probe.openStream(5000, 1000) as Stream; - let count = 0; - for (let i = 0; i < 200; i++) { - const chunk = await s1.read(4096); - if ((chunk as { length: number }).length === 0) break; - count += (chunk as Uint8Array).length; - } - assertEq(count, 5000, "host read the full 5000-byte exported stream"); - - // Probe 4b — reader dropped mid-stream: the writer must observe - // resolution ("reader stopped after N bytes"), not trap. - const s2 = await probe.openStream(100000, 1000) as Stream; - let count2 = 0; - while (count2 < 2500) { - const chunk = await s2.read(1024); - if ((chunk as { length: number }).length === 0) break; - count2 += (chunk as Uint8Array).length; - } - assertTrue(count2 >= 2500, "read at least 2500 bytes before dropping"); - s2.drop(); - const outcome = await probe.streamOutcome(); - assertTrue( - typeof outcome === "string" && outcome.includes("reader stopped"), - `writer observed resolution, not a trap: ${outcome}`, - ); - - // Probe 5 — host-provided stream into the guest. - const payload = new Array(500).fill(0x33); - const n = await probe.sinkStream(payload); - assertEq(n, 500, "guest counted every byte of the host-provided stream"); - }, -}); diff --git a/ports/webcrypto/tests/keyAgreement_test.ts b/ports/webcrypto/tests/keyAgreement_test.ts deleted file mode 100644 index f704ff6..0000000 --- a/ports/webcrypto/tests/keyAgreement_test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Unit tests: `polymorph:webcrypto/key-agreement` + `x25519` (RFC 7748) — -// the shape wasi-shims's `webcryptoFixture()` test-local glue hand-rolled -// (wasi-shims/tests/integration_exec_model_test.ts:66-86); this is the real -// port's coverage of the same family plus `agree`/`derive-bits`. - -import { assertEq, assertRejects } from "./asserts.ts"; -import { AgreementKeyOptions, x25519 } from "../src/mod.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -function agreeOptions(): AgreementKeyOptions { - const o = new AgreementKeyOptions(); - o.canDeriveBits(true); - o.canDeriveKey(true); - return o; -} - -Deno.test("x25519: generate-key -> export-key-raw round-trips a 32-byte u-coordinate", async () => { - const [, pub] = await x25519.generateKey(agreeOptions()); - const raw = await pub.exportKeyRaw(); - assertEq(raw.length, 32); - assertEq(pub.algorithmName(), "X25519"); -}); - -Deno.test("key-agreement: agree() + derive-bits(none) yields the natural 32-byte shared secret, symmetric both ways", async () => { - const [skA, pubA] = await x25519.generateKey(agreeOptions()); - const [skB, pubB] = await x25519.generateKey(agreeOptions()); - const inputA = await skA.agree(pubB); - const inputB = await skB.agree(pubA); - const secretA = await inputA.deriveBits(undefined); - const secretB = await inputB.deriveBits(undefined); - assertEq(secretA.length, 32); - assertEq([...secretA].join(","), [...secretB].join(",")); -}); - -Deno.test("key-agreement: derive-bits(length) yields exactly `length` bits", async () => { - const [skA, pubA] = await x25519.generateKey(agreeOptions()); - const [, pubB] = await x25519.generateKey(agreeOptions()); - const input = await skA.agree(pubB); - const bits = await input.deriveBits(128); - assertEq(bits.length, 16); - void pubA; -}); - -Deno.test("key-agreement: derive-bits without can-derive-bits fails error.not-permitted", async () => { - const opts = new AgreementKeyOptions(); - opts.canDeriveKey(true); // deriveBits NOT granted - const [sk] = await x25519.generateKey(opts); - const [, pub2] = await x25519.generateKey(agreeOptions()); - const input = await sk.agree(pub2); - const err = await assertRejects(() => input.deriveBits(undefined)) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "not-permitted"); -}); - -Deno.test("x25519: import-public-key-raw with wrong length fails error.invalid-key", async () => { - const err = await assertRejects(() => x25519.importPublicKeyRaw(new Uint8Array(10))) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "invalid-key"); -}); diff --git a/ports/webcrypto/tests/mac_test.ts b/ports/webcrypto/tests/mac_test.ts deleted file mode 100644 index 8e57d12..0000000 --- a/ports/webcrypto/tests/mac_test.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Unit tests: `polymorph:webcrypto/mac` + `hmac-sha2`/`hmac-sha1`. -// -// KAT source: conformance/vectors/hmac_sha256_test.json (Wycheproof-format; -// tcId 1, "empty message" — read by path, not reproduced as a literal blob -// here beyond what the test needs). - -import { assertEq, assertRejects } from "./asserts.ts"; -import { hmacSha1, hmacSha2, MacKeyOptions } from "../src/mod.ts"; -import { arrayStream } from "./testStream.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; - -function hexToBytes(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - return out; -} -function hex(bytes: Uint8Array): string { - return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -function signOptions(): MacKeyOptions { - const o = new MacKeyOptions(); - o.canSign(true); - o.canVerify(true); - return o; -} - -Deno.test("hmac-sha2: sign/verify roundtrip over sha256", async () => { - const key = await hmacSha2.importKeyRaw("sha256", new Uint8Array(32).fill(0x11), signOptions()); - const tag = await key.sign(arrayStream(new TextEncoder().encode("hello"))); - await key.verify(arrayStream(new TextEncoder().encode("hello")), tag); - assertEq(key.algorithmName(), "HMAC"); - assertEq(key.algorithmHash(), "SHA-256"); -}); - -Deno.test("hmac-sha2: KAT against conformance/vectors/hmac_sha256_test.json tcId 1", async () => { - let raw: string; - try { - raw = await Deno.readTextFile(`${VECTORS_DIR}/hmac_sha256_test.json`); - } catch { - console.log(" (skip: polymorph-webcrypto vectors tree not present)"); - return; - } - const doc = JSON.parse(raw); - const tc = doc.testGroups[0].tests.find((t: { tcId: number }) => t.tcId === 1); - const key = await hmacSha2.importKeyRaw("sha256", hexToBytes(tc.key), signOptions()); - const tag = await key.sign(arrayStream(hexToBytes(tc.msg))); - assertEq(hex(tag), tc.tag.toLowerCase()); -}); - -Deno.test("hmac-sha1: sign/verify roundtrip", async () => { - const key = await hmacSha1.importKeyRaw(new Uint8Array(20).fill(0x22), signOptions()); - const tag = await key.sign(arrayStream(new TextEncoder().encode("msg"))); - await key.verify(arrayStream(new TextEncoder().encode("msg")), tag); -}); - -Deno.test("mac: sign on a verify-only key fails error.not-permitted (the usage-grant taxonomy case)", async () => { - const opts = new MacKeyOptions(); - opts.canVerify(true); // sign NOT granted - const key = await hmacSha2.importKeyRaw("sha256", new Uint8Array(32).fill(0x33), opts); - const err = await assertRejects(() => key.sign(arrayStream(new Uint8Array(0)))) as ComponentException; - assertEq((err.payload as { kind: string; value: string }).kind, "not-permitted"); -}); - -Deno.test("mac: verify with a wrong tag fails error.authentication-failed", async () => { - const key = await hmacSha2.importKeyRaw("sha256", new Uint8Array(32).fill(0x44), signOptions()); - const wrongTag = new Uint8Array(32); // all-zero: not the real tag - const err = await assertRejects( - () => key.verify(arrayStream(new TextEncoder().encode("data")), wrongTag), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "authentication-failed"); -}); - -Deno.test("mac: importKeyRaw with empty material fails error.invalid-key", async () => { - const err = await assertRejects( - () => hmacSha2.importKeyRaw("sha256", new Uint8Array(0), signOptions()), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "invalid-key"); -}); - -Deno.test("mac: export-key-raw on a non-extractable key fails error.not-extractable", async () => { - const opts = new MacKeyOptions(); - opts.canSign(true); - // extractable NOT granted (default false, per the package-wide options contract) - const key = await hmacSha2.importKeyRaw("sha256", new Uint8Array(32).fill(0x55), opts); - const err = await assertRejects(() => key.exportKeyRaw()) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "not-extractable"); -}); diff --git a/ports/webcrypto/tests/signature_test.ts b/ports/webcrypto/tests/signature_test.ts deleted file mode 100644 index 4048883..0000000 --- a/ports/webcrypto/tests/signature_test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Unit tests: `polymorph:webcrypto/signature` + `ed25519-verify`/`ed25519-sign` -// (the iroh identity path's primitive — mission context). -// -// KAT source: conformance/vectors/ed25519_test.json (Wycheproof-format -// group 0: empty-message signature under the group's public key). - -import { assertEq, assertRejects } from "./asserts.ts"; -import { ed25519Sign, ed25519Verify, SigningKeyOptions } from "../src/mod.ts"; -import { arrayStream } from "./testStream.ts"; -import { ComponentException } from "../../../runtime/src/embedder/errors.ts"; - -const VECTORS_DIR = "/home/lmartin/p/polymorph/polymorph-webcrypto/conformance/vectors"; - -function hexToBytes(hex: string): Uint8Array { - const out = new Uint8Array(hex.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - return out; -} - -Deno.test("ed25519: generate-key -> sign -> verify roundtrip", async () => { - const opts = new SigningKeyOptions(); - opts.canSign(true); - const [sk, vk] = await ed25519Sign.generateKey(opts); - const sig = await sk.sign(arrayStream(new TextEncoder().encode("iroh identity payload"))); - await vk.verify(arrayStream(new TextEncoder().encode("iroh identity payload")), sig); - assertEq(vk.algorithmName(), "Ed25519"); - assertEq(vk.algorithmCurve(), undefined); - assertEq(vk.algorithmHash(), undefined); -}); - -Deno.test("ed25519: KAT against conformance/vectors/ed25519_test.json group 0 tcId 1", async () => { - let raw: string; - try { - raw = await Deno.readTextFile(`${VECTORS_DIR}/ed25519_test.json`); - } catch { - console.log(" (skip: polymorph-webcrypto vectors tree not present)"); - return; - } - const doc = JSON.parse(raw); - const group = doc.testGroups[0]; - const tc = group.tests.find((t: { tcId: number }) => t.tcId === 1); - const vk = await ed25519Verify.importVerifyingKeyRaw(hexToBytes(group.publicKey.pk)); - await vk.verify(arrayStream(hexToBytes(tc.msg)), hexToBytes(tc.sig)); -}); - -Deno.test("ed25519: verify with a tampered signature fails error.authentication-failed", async () => { - const opts = new SigningKeyOptions(); - opts.canSign(true); - const [sk, vk] = await ed25519Sign.generateKey(opts); - const sig = await sk.sign(arrayStream(new TextEncoder().encode("payload"))); - sig[0] ^= 0xff; // deliberately-corrupted signature (synthetic, not real key material) - const err = await assertRejects( - () => vk.verify(arrayStream(new TextEncoder().encode("payload")), sig), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "authentication-failed"); -}); - -Deno.test("ed25519: import-verifying-key-raw with wrong length fails error.invalid-key", async () => { - const err = await assertRejects( - () => ed25519Verify.importVerifyingKeyRaw(new Uint8Array(16)), - ) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "invalid-key"); -}); - -Deno.test("ed25519: sign without can-sign fails error.not-permitted (a untouched options resource cannot mint)", async () => { - const opts = new SigningKeyOptions(); // canSign never called - const err = await assertRejects(() => ed25519Sign.generateKey(opts)) as ComponentException; - assertEq((err.payload as { kind: string }).kind, "not-permitted"); -}); diff --git a/ports/webcrypto/tests/testStream.ts b/ports/webcrypto/tests/testStream.ts deleted file mode 100644 index ea77cbe..0000000 --- a/ports/webcrypto/tests/testStream.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Minimal `Stream`-shaped test double: the port's stream-consuming -// functions (`collectByteStream`) only call `.read(max)`, so a fake -// implementing that one method is sufficient and avoids depending on the -// runtime's full stream/store machinery for unit tests (the exec-model -// integration test exercises the real `Stream` handle end-to-end). - -import type { Stream } from "../../../runtime/src/embedder/mod.ts"; - -export function arrayStream(bytes: Uint8Array): Stream { - let offset = 0; - return { - async read(max: number) { - if (offset >= bytes.length) return new Uint8Array(0); - const end = Math.min(bytes.length, offset + max); - const chunk = bytes.slice(offset, end); - offset = end; - return chunk; - }, - // deno-lint-ignore no-explicit-any - } as any as Stream; -} diff --git a/ports/webrtc/conformance/echo-demo.ts b/ports/webrtc/conformance/echo-demo.ts deleted file mode 100644 index 75b2487..0000000 --- a/ports/webrtc/conformance/echo-demo.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Gate 2: run the consumer's REAL echo-demo component end-to-end through -// deltic, with `ports/webrtc` supplying -// `polymorph:webrtc-datachannels/connections@0.1.0` — mirroring -// `ports/websocket/conformance/run.ts`'s pattern (Translator.create + the -// shim wasm32 module, run AT RUNTIME under Deno, not a build-time step). -// -// The world under test (`webrtc-echo-demo`, -// polymorph-webrtc-datachannels/examples/echo-demo/wit/webrtc-echo.wit) -// imports ONLY `connections` and exports `demo.run` — the component itself -// stands up BOTH peers (an offerer and an in-component echo answerer) -// through the standard `connections` interface, so no rendezvous/signaling -// import is needed for this artifact (unlike `webrtc-echo-remote`, which -// needs an additional `rendezvous` import for a genuine two-process run — -// out of scope here). -// -// Skip-if-absent: the artifact and the translator shim are real build -// products, not checked in here; if either is missing this script reports -// precisely why rather than failing loudly on a laptop. -// -// deno run -A ports/webrtc/conformance/echo-demo.ts - -import { Translator } from "../../../runtime/src/shim/mod.ts"; -import type { ComponentArtifacts } from "@deltic/runtime/embedder"; -import { instantiate } from "@deltic/runtime/embedder"; -import { webrtcImports } from "../src/webrtc.ts"; - -const CE_ROOT = new URL("../../../", import.meta.url).pathname; -const CONSUMER = "/home/lmartin/p/polymorph/polymorph-webrtc-datachannels"; -// The committed artifact (`examples/echo-demo/build/echo-demo.component.wasm`, -// gitignored per the consumer's own .gitignore) predates a package rename -// (`lann:webrtc-datachannels` -> `polymorph:webrtc-datachannels`) and fails -// to link against this port. `ensureArtifact` rebuilds a fresh one OUTSIDE -// the consumer tree (redirected `CARGO_TARGET_DIR`, matching -// ports/websocket/conformance/run.ts's `ensureEchod` discipline), reading -// only `examples/echo-demo/{Cargo.toml,src,wit}` — nothing here writes into -// the consumer tree. -const REBUILD_TARGET = "/tmp/opencode/c3-rtc-target"; -const ARTIFACT = "/tmp/opencode/c3-rtc-build/echo-demo.component.wasm"; -const SHIM_WASM = `${CE_ROOT}target/wasm32-unknown-unknown/release/translator_shim.wasm`; - -async function ensureArtifact(): Promise { - try { - const st = await Deno.stat(ARTIFACT); - if (st.isFile) return; - } catch { /* build it */ } - console.error(`building echo-demo.component.wasm into ${REBUILD_TARGET} …`); - const built = await new Deno.Command("cargo", { - args: [ - "build", - "--release", - "-p", - "echo-demo", - "--target", - "wasm32-unknown-unknown", - "--manifest-path", - `${CONSUMER}/Cargo.toml`, - ], - env: { CARGO_TARGET_DIR: REBUILD_TARGET }, - stdout: "inherit", - stderr: "inherit", - }).output(); - if (!built.success) throw new Error("echo-demo build failed"); - await Deno.mkdir("/tmp/opencode/c3-rtc-build", { recursive: true }); - const wasmTools = await new Deno.Command("wasm-tools", { - args: [ - "component", - "new", - `${REBUILD_TARGET}/wasm32-unknown-unknown/release/echo_demo.wasm`, - "-o", - ARTIFACT, - ], - stdout: "inherit", - stderr: "inherit", - }).output(); - if (!wasmTools.success) throw new Error("wasm-tools component new failed"); -} - -async function readIfPresent(path: string): Promise { - try { - return await Deno.readFile(path); - } catch { - return null; - } -} - -async function loadArtifacts(bytes: Uint8Array): Promise { - const shimBytes = await Deno.readFile(SHIM_WASM); - const translator = await Translator.create(shimBytes); - const { plan, adapters } = translator.translate(bytes); - return { plan, componentBytes: bytes, adapters }; -} - -interface DemoStats { - messagesSent: number; - messagesReceived: number; - bytesEchoed: bigint; -} - -async function main() { - await ensureArtifact(); - const artifactBytes = await readIfPresent(ARTIFACT); - if (artifactBytes === null) { - console.error(`SKIP: echo-demo artifact still not found at ${ARTIFACT} after build.`); - Deno.exit(0); - } - const shimBytes = await readIfPresent(SHIM_WASM); - if (shimBytes === null) { - console.error( - `SKIP: translator shim not found at ${SHIM_WASM} — build it with:\n` + - ` cargo build -p translator-shim --target wasm32-unknown-unknown --release`, - ); - Deno.exit(0); - } - - console.error(`translating ${ARTIFACT} …`); - const artifacts = await loadArtifacts(artifactBytes); - console.error( - `plan loaded: ${artifacts.plan.imports.length} import(s), ` + - `${artifacts.plan.exports.length} export(s)`, - ); - for (const exp of artifacts.plan.exports) { - console.error(` export: ${exp.name}`); - } - - const imports = { ...webrtcImports() }; - const instance = await instantiate(artifacts, imports); - - // The world exports the `demo` interface; find its export by the - // fully-qualified WIT id (contracts/embedder-api.md: "interface key in - // the imports/exports record: fully-qualified WIT id verbatim, version - // included"). - const demoExport = artifacts.plan.exports.find((e) => - e.name.includes("webrtc-echo/demo") - ); - if (!demoExport) { - console.error( - "BLOCKED: no export matching '.../demo@...' found in plan.exports:\n" + - artifacts.plan.exports.map((e: { name: string }) => ` ${e.name}`).join("\n"), - ); - Deno.exit(1); - } - // deno-lint-ignore no-explicit-any - const demo = instance.exports[demoExport.name] as any; - console.error(`resolved export "${demoExport.name}": run=${typeof demo.run}`); - - const MESSAGE_COUNT = 50; - const MESSAGE_SIZE = 512; - const started = performance.now(); - const stats = await demo.run({ - messageCount: MESSAGE_COUNT, - messageSize: MESSAGE_SIZE, - }) as DemoStats; - const elapsed = performance.now() - started; - - console.error("echo-demo (deltic / ports/webrtc host) result:"); - console.error(` messages sent: ${stats.messagesSent}`); - console.error(` messages received: ${stats.messagesReceived}`); - console.error(` bytes echoed: ${stats.bytesEchoed}`); - console.error(` elapsed: ${elapsed.toFixed(1)} ms`); - - const expectedBytes = BigInt(MESSAGE_COUNT * MESSAGE_SIZE); - if (stats.messagesSent !== MESSAGE_COUNT) { - throw new Error(`expected ${MESSAGE_COUNT} sent, got ${stats.messagesSent}`); - } - if (stats.messagesReceived !== MESSAGE_COUNT) { - throw new Error(`expected ${MESSAGE_COUNT} received, got ${stats.messagesReceived}`); - } - if (stats.bytesEchoed !== expectedBytes) { - throw new Error(`expected ${expectedBytes} bytes echoed, got ${stats.bytesEchoed}`); - } - console.error("\nOK: every message round-tripped through the WebRTC data channel."); -} - -if (import.meta.main) await main(); diff --git a/ports/webrtc/deno.json b/ports/webrtc/deno.json deleted file mode 100644 index 6223913..0000000 --- a/ports/webrtc/deno.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "nodeModulesDir": "auto", - "//": "This package imports the runtime by relative path (like ports/websocket). The one alias below is what ports/websocket itself uses internally; it resolves to the same file URL as the relative imports here, so there is exactly one `ComponentException`/`Stream` module instance and `instanceof` holds across the boundary (contracts/embedder-api.md's error-model brand hazard).", - "imports": { - "@deltic/runtime/embedder": "../../runtime/src/embedder/mod.ts" - }, - "tasks": { - "test": "deno test -A webrtc.test.ts", - "demo": "deno run -A conformance/echo-demo.ts" - } -} diff --git a/ports/webrtc/deno.lock b/ports/webrtc/deno.lock deleted file mode 100644 index 64077dc..0000000 --- a/ports/webrtc/deno.lock +++ /dev/null @@ -1,601 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.19", - "jsr:@std/internal@^1.0.12": "1.0.14", - "npm:node-datachannel@0.32.3": "0.32.3", - "npm:werift@0.22.2": "0.22.2" - }, - "jsr": { - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.14": { - "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - } - }, - "npm": { - "@fidm/asn1@1.0.4": { - "integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==" - }, - "@fidm/x509@1.2.1": { - "integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==", - "dependencies": [ - "@fidm/asn1", - "tweetnacl" - ] - }, - "@leichtgewicht/ip-codec@2.0.5": { - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" - }, - "@minhducsun2002/leb128@1.0.0": { - "integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==" - }, - "@noble/curves@1.9.7": { - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dependencies": [ - "@noble/hashes" - ] - }, - "@noble/hashes@1.8.0": { - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" - }, - "@peculiar/asn1-cms@2.8.0": { - "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-attr", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-csr@2.8.0": { - "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-ecc@2.8.0": { - "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pfx@2.8.0": { - "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pkcs8@2.8.0": { - "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-pkcs9@2.8.0": { - "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-pfx", - "@peculiar/asn1-pkcs8", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "@peculiar/asn1-x509-attr", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-rsa@2.8.0": { - "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-schema@2.8.0": { - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dependencies": [ - "@peculiar/utils", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509-attr@2.8.0": { - "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/asn1-x509@2.8.0": { - "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "dependencies": [ - "@peculiar/asn1-schema", - "@peculiar/utils", - "asn1js", - "tslib@2.8.1" - ] - }, - "@peculiar/utils@2.0.3": { - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "@peculiar/x509@1.14.3": { - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "dependencies": [ - "@peculiar/asn1-cms", - "@peculiar/asn1-csr", - "@peculiar/asn1-ecc", - "@peculiar/asn1-pkcs9", - "@peculiar/asn1-rsa", - "@peculiar/asn1-schema", - "@peculiar/asn1-x509", - "pvtsutils", - "reflect-metadata", - "tslib@2.8.1", - "tsyringe" - ] - }, - "@shinyoshiaki/binary-data@0.6.1": { - "integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==", - "dependencies": [ - "generate-function", - "is-plain-object" - ] - }, - "@shinyoshiaki/jspack@0.0.6": { - "integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==" - }, - "@types/dom-mediacapture-transform@0.1.12": { - "integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==", - "dependencies": [ - "@types/dom-webcodecs" - ] - }, - "@types/dom-webcodecs@0.1.13": { - "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==" - }, - "aes-js@3.1.2": { - "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" - }, - "asn1js@3.0.10": { - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dependencies": [ - "pvtsutils", - "pvutils", - "tslib@2.8.1" - ] - }, - "base64-js@1.5.1": { - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "big-integer@1.6.52": { - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==" - }, - "bl@4.1.0": { - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": [ - "buffer@5.7.1", - "inherits", - "readable-stream" - ] - }, - "buffer-crc32@1.0.0": { - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==" - }, - "buffer@5.7.1": { - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "buffer@6.0.3": { - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "chownr@1.1.4": { - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "date-fns@4.4.0": { - "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==" - }, - "debug@4.4.0": { - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dependencies": [ - "ms" - ] - }, - "decompress-response@6.0.0": { - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": [ - "mimic-response" - ] - }, - "deep-extend@0.6.0": { - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" - }, - "detect-libc@2.1.2": { - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" - }, - "dns-packet@5.6.1": { - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dependencies": [ - "@leichtgewicht/ip-codec" - ] - }, - "end-of-stream@1.4.5": { - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": [ - "once" - ] - }, - "expand-template@2.0.3": { - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" - }, - "fs-constants@1.0.0": { - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "generate-function@2.3.1": { - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "dependencies": [ - "is-property" - ] - }, - "github-from-package@0.0.0": { - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "ieee754@1.2.1": { - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, - "inherits@2.0.4": { - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "ini@1.3.8": { - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "int64-buffer@1.1.0": { - "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==" - }, - "ip@2.0.1": { - "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" - }, - "is-plain-object@2.0.4": { - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dependencies": [ - "isobject" - ] - }, - "is-property@1.0.2": { - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" - }, - "isobject@3.0.1": { - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==" - }, - "lodash@4.18.1": { - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" - }, - "mediabunny@1.52.3": { - "integrity": "sha512-rMGwH5fykDCSA55LG9aWkE433wwHrycq3J5mRf+djBnHBZzmJGvIwg6Qfcfr4rRkzkmrdmewxQozLkOM1H1C6Q==", - "dependencies": [ - "@types/dom-mediacapture-transform", - "@types/dom-webcodecs" - ] - }, - "mimic-response@3.1.0": { - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" - }, - "minimist@1.2.8": { - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - }, - "mkdirp-classic@0.5.3": { - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "mp4box@0.5.4": { - "integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==" - }, - "ms@2.1.3": { - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "multicast-dns@7.2.5": { - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dependencies": [ - "dns-packet", - "thunky" - ], - "bin": true - }, - "nano-time@1.0.0": { - "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==", - "dependencies": [ - "big-integer" - ] - }, - "napi-build-utils@2.0.0": { - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" - }, - "node-abi@3.94.0": { - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "dependencies": [ - "semver" - ] - }, - "node-datachannel@0.32.3": { - "integrity": "sha512-Aok1ZhLsll472lRefgWYuWJ0070jh0ecHravTdRyZEmoESumebMEQV8Y+poBwSW2ZbEwAokAOGsK5Cu8pDDT2g==", - "dependencies": [ - "prebuild-install" - ], - "scripts": true - }, - "once@1.4.0": { - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": [ - "wrappy" - ] - }, - "p-cancelable@2.1.1": { - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" - }, - "prebuild-install@7.1.3": { - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dependencies": [ - "detect-libc", - "expand-template", - "github-from-package", - "minimist", - "mkdirp-classic", - "napi-build-utils", - "node-abi", - "pump", - "rc", - "simple-get", - "tar-fs", - "tunnel-agent" - ], - "deprecated": true, - "bin": true - }, - "pump@3.0.4": { - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dependencies": [ - "end-of-stream", - "once" - ] - }, - "pvtsutils@1.3.6": { - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dependencies": [ - "tslib@2.8.1" - ] - }, - "pvutils@1.2.0": { - "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==" - }, - "rc@1.2.8": { - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": [ - "deep-extend", - "ini", - "minimist", - "strip-json-comments" - ], - "bin": true - }, - "readable-stream@3.6.2": { - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": [ - "inherits", - "string_decoder", - "util-deprecate" - ] - }, - "reflect-metadata@0.2.2": { - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" - }, - "rx.mini@1.4.0": { - "integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==" - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "semver@7.8.5": { - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "bin": true - }, - "simple-concat@1.0.1": { - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==" - }, - "simple-get@4.0.1": { - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dependencies": [ - "decompress-response", - "once", - "simple-concat" - ] - }, - "string_decoder@1.3.0": { - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": [ - "safe-buffer" - ] - }, - "strip-json-comments@2.0.1": { - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==" - }, - "tar-fs@2.1.5": { - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dependencies": [ - "chownr", - "mkdirp-classic", - "pump", - "tar-stream" - ] - }, - "tar-stream@2.2.0": { - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": [ - "bl", - "end-of-stream", - "fs-constants", - "inherits", - "readable-stream" - ] - }, - "thunky@1.1.0": { - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" - }, - "tslib@1.14.1": { - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, - "tslib@2.8.1": { - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "tsyringe@4.10.0": { - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dependencies": [ - "tslib@1.14.1" - ] - }, - "tunnel-agent@0.6.0": { - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": [ - "safe-buffer" - ] - }, - "turbo-crc32@1.0.1": { - "integrity": "sha512-8yyRd1ZdNp+AQLGqi3lTaA2k81JjlIZOyFQEsi7GQWBgirnQOxjqVtDEbYHM2Z4yFdJ5AQw0fxBLLnDCl6RXoQ==" - }, - "tweetnacl@1.0.3": { - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" - }, - "util-deprecate@1.0.2": { - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "uuid@11.1.1": { - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "bin": true - }, - "werift-common@0.0.3": { - "integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==", - "dependencies": [ - "@shinyoshiaki/jspack", - "debug" - ] - }, - "werift-dtls@0.5.8": { - "integrity": "sha512-oH39cdCpVLqKcPZRMGQLcN+tIBixvMGqjCB9qCrH39Tu9Mmu8u2+zTzyl0Fod87EK6PNPgoNqMJt64xlSMfLnw==", - "dependencies": [ - "@fidm/x509", - "@noble/curves", - "@peculiar/x509", - "@shinyoshiaki/binary-data", - "debug", - "tweetnacl" - ] - }, - "werift-ice@0.2.2": { - "integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==", - "dependencies": [ - "@shinyoshiaki/jspack", - "buffer-crc32", - "debug", - "int64-buffer", - "ip", - "lodash", - "multicast-dns", - "p-cancelable", - "rx.mini" - ] - }, - "werift-rtp@0.8.9": { - "integrity": "sha512-uLFOawIXw8FblIp1akfIVYFuRNtNo7csRxYypLz0t++sUNCN7sPeghVqET/c3Mq7Tg2QA3oAb15fXIIuJEp2AA==", - "dependencies": [ - "buffer@6.0.3", - "debug", - "mediabunny" - ] - }, - "werift-sctp@0.0.11": { - "integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==", - "dependencies": [ - "@shinyoshiaki/jspack" - ] - }, - "werift@0.22.2": { - "integrity": "sha512-R+dfzOknUiGH8EcxGjWfN4404+Npj4tT1L5HpqZLjw0ARCO0B19i9gAQOo6ESzzTE+L8L1wxb1KIspOeoko+TQ==", - "dependencies": [ - "@fidm/x509", - "@minhducsun2002/leb128", - "@noble/curves", - "@peculiar/x509", - "@shinyoshiaki/binary-data", - "@shinyoshiaki/jspack", - "aes-js", - "buffer@6.0.3", - "buffer-crc32", - "date-fns", - "debug", - "int64-buffer", - "ip", - "lodash", - "mp4box", - "multicast-dns", - "nano-time", - "turbo-crc32", - "tweetnacl", - "uuid", - "werift-common", - "werift-dtls", - "werift-ice", - "werift-rtp", - "werift-sctp" - ] - }, - "wrappy@1.0.2": { - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - } - }, - "workspace": { - "packageJson": { - "dependencies": [ - "npm:node-datachannel@0.32.3", - "npm:werift@0.22.2" - ] - }, - "links": { - "jsr:@deltic/ct-runner@0.1.0": {}, - "jsr:@deltic/protocol@0.2.0": {}, - "jsr:@deltic/runtime@0.1.0": {}, - "jsr:@deltic/translator@0.1.0": {}, - "jsr:@deltic/wasi-shims@0.1.0": {} - } - } -} diff --git a/ports/webrtc/package.json b/ports/webrtc/package.json deleted file mode 100644 index 3f8f47f..0000000 --- a/ports/webrtc/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dependencies": { - "node-datachannel": "0.32.3", - "werift": "0.22.2" - } -} diff --git a/ports/webrtc/src/types.ts b/ports/webrtc/src/types.ts deleted file mode 100644 index 5c18f22..0000000 --- a/ports/webrtc/src/types.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Structural types for `polymorph:webrtc-datachannels/types@0.1.0`, ported to -// the embedder conventions (contracts/embedder-api.md §"Value mapping"). -// -// Authority: wit/webrtc.wit `interface types` (polymorph-webrtc-datachannels, -// read-only reference). Enums are kebab-case string literal unions; variants -// are `{ kind, value? }`; records are plain camelCase objects. - -import type { Stream, StreamSource } from "@deltic/runtime/embedder"; - -// --- error ----------------------------------------------------------------- - -export type WebrtcError = - | { kind: "closed" } - | { kind: "timed-out" } - | { kind: "invalid-signaling"; value: string } - | { kind: "receiving-via-stream" } - | { kind: "receive-buffer-overflow" } - | { kind: "other"; value: string }; - -// --- message ----------------------------------------------------------------- - -export type Message = - | { kind: "binary"; value: Uint8Array } - | { kind: "string"; value: string }; - -export const Message = { - binary(bytes: Uint8Array): Message { - return { kind: "binary", value: bytes }; - }, - string(text: string): Message { - return { kind: "string", value: text }; - }, -}; - -// --- message-kind ------------------------------------------------------------ - -export type MessageKind = "binary" | "string"; - -// --- stream-message ------------------------------------------------------------ - -export interface StreamMessage { - kind: MessageKind; - length: number; - data: StreamSource; -} - -/** - * The lifted shape of `stream-message` when the guest hands one over (as a - * parameter to `send-via-stream`): `data` arrives as a `Stream` handle, - * not a producer the port constructs. Mirrors - * `ports/websocket/src/websocket.ts`'s `LiftedStreamMessage`. - */ -export interface LiftedStreamMessage { - kind: MessageKind; - length: number; - data: Stream; -} - -// --- send-via-stream-error ----------------------------------------------------- - -export interface SendViaStreamError { - error: WebrtcError; - sent: bigint; -} - -// --- sdp-type ------------------------------------------------------------------ - -export type SdpType = "offer" | "answer" | "pranswer" | "rollback"; - -// --- session-description --------------------------------------------------------- - -export interface SessionDescription { - kind: SdpType; - sdp: string; -} - -// --- ice-candidate ----------------------------------------------------------------- - -export interface IceCandidate { - candidate: string; - sdpMid?: string; - sdpMlineIndex?: number; -} - -// --- config-error ------------------------------------------------------------------ - -export type ConfigError = - | { kind: "not-supported" } - | { kind: "invalid"; value: string }; - -// --- ice-server -------------------------------------------------------------------- - -export interface IceServer { - urls: string[]; - username: string; - credential: string; -} - -// --- ice-transport-policy ------------------------------------------------------------ - -export type IceTransportPolicy = "all" | "relay"; - -// --- connection-state ---------------------------------------------------------------- - -export type ConnectionState = - | "new" - | "connecting" - | "connected" - | "disconnected" - | "failed" - | "closed"; - -// --- data-channel-state -------------------------------------------------------------- - -export type DataChannelState = "connecting" | "open" | "closing" | "closed"; diff --git a/ports/webrtc/src/webrtc.ts b/ports/webrtc/src/webrtc.ts deleted file mode 100644 index efa5258..0000000 --- a/ports/webrtc/src/webrtc.ts +++ /dev/null @@ -1,1159 +0,0 @@ -// Host-module port of `polymorph:webrtc-datachannels/connections@0.1.0` to -// the embedder conventions (contracts/embedder-api.md), backed by the -// Deno-verified WebRTC path (the retired C0 probe — see docs/consumers.md -// "Deno substitutes for Node" — proved both node-datachannel's Node-API -// addon and pure-TS werift pass full data-channel loopbacks under this -// exact Deno). -// -// Ported from the browser-first reference -// (`polymorph/polymorph-webrtc-datachannels/jco-impl/webrtc.js`, read-only -// authority) written against the standard W3C `RTCPeerConnection` / -// `RTCDataChannel` API. Behavior is preserved; only the boundary conventions -// change: -// - thrown bare `{ tag, val }` payloads become `throw new ComponentException(payload)` -// (contracts/embedder-api.md §"Error model" — "Host import with -// result": throw new ComponentException(payload) for err). -// - jco `Stream`/`ReadableStream` params/results become the runtime's real -// `Stream` (consumed, e.g. `send-via-stream`'s guest-provided -// messages) / `ReadableStream` (produced, e.g. `receive-via-stream`'s -// result — one of the natural JS producers the conventions accept where -// a `stream` is expected). Imported from -// `@deltic/runtime/embedder` (aliased in this package's -// `deno.json` to the exact same file the runtime and every other port -// use), NOT reimplemented locally: `ComponentException` is a plain branded class -// with no `Store` involvement, so a local clone would produce a second -// class identity and every `throw` from this port would fail -// `instanceof ComponentException` at a real component boundary — silently -// becoming an unbranded-throw trap instead of a guest-visible err. This -// is exactly the cross-package brand hazard `ports/websocket/deno.json` -// documents and solves the same way. -// - the reference's module-level `setMaxInboundBufferBytes` becomes a -// `PeerConnectionConfig`... no: the WIT does not expose the inbound -// buffer bound as guest-configurable (it is host policy per the -// resource's doc comment), so it stays a module-level setter here too, -// unchanged from the reference — CONTRACT: this is the same -// interpretation the reference authors made; the WIT gives no accessor -// for it, so there is no guest-facing shape to convert. - -import { Stream, type StreamSource, ComponentException } from "@deltic/runtime/embedder"; -import type { - ConfigError, - ConnectionState, - DataChannelState, - IceCandidate, - IceServer, - IceTransportPolicy, - LiftedStreamMessage, - Message, - SendViaStreamError, - SessionDescription, - StreamMessage, - WebrtcError, -} from "./types.ts"; - -// --- isomorphic RTCPeerConnection resolution -------------------------------- - -/** - * Whether the backend resolves `createOffer` only once the connection has - * negotiation material (true of libdatachannel, which derives descriptions - * from the media/data sections that exist); see `PeerConnection.createOffer`. - * Ported verbatim from the reference (jco-impl/webrtc.js:28). - */ -let offerNeedsChannel = false; - -// deno-lint-ignore no-explicit-any -type RTCPeerConnectionCtor = new (config?: unknown) => any; - -let cachedRTCPeerConnection: RTCPeerConnectionCtor | undefined; - -/** - * Resolve `RTCPeerConnection` isomorphically: a browser (including headless - * Chromium) exposes the W3C class as a global; under Deno/Node it is - * provided by `node-datachannel`'s polyfill, imported lazily so the bare - * specifier never has to resolve in the browser. Ported from - * jco-impl/webrtc.js:30-43. - */ -async function resolveRTCPeerConnection(): Promise { - if (cachedRTCPeerConnection) return cachedRTCPeerConnection; - // deno-lint-ignore no-explicit-any - const g = globalThis as any; - if (g.RTCPeerConnection) { - cachedRTCPeerConnection = g.RTCPeerConnection; - return cachedRTCPeerConnection!; - } - try { - const { RTCPeerConnection } = await import("node-datachannel/polyfill"); - offerNeedsChannel = true; - cachedRTCPeerConnection = RTCPeerConnection as RTCPeerConnectionCtor; - return cachedRTCPeerConnection; - } catch (cause) { - throw new Error( - "no RTCPeerConnection available: not running in a browser and " + - "node-datachannel could not be loaded (run `deno install " + - "--allow-scripts=npm:node-datachannel` in ports/webrtc)", - { cause }, - ); - } -} - -/** - * Test/embedder hook: force the pure-TS werift fallback instead of - * node-datachannel. werift's `RTCPeerConnection` is also W3C-shaped, so the - * same resolver slot works; `offerNeedsChannel` is werift-specific behavior - * (untested — see report) and is left at its default (`false`) for this - * path since werift's `createOffer` does not require pre-existing - * negotiation material. - */ -export async function useWerift(): Promise { - const { RTCPeerConnection } = await import("werift"); - cachedRTCPeerConnection = RTCPeerConnection as unknown as RTCPeerConnectionCtor; - offerNeedsChannel = false; -} - -/** Test hook: reset resolution so the next construction re-resolves. */ -export function resetResolvedBackend(): void { - cachedRTCPeerConnection = undefined; - offerNeedsChannel = false; -} - -// --- tunables (module-level, matching the reference) ------------------------ - -/** Keep the SCTP send buffer bounded; pause the producer when it fills. */ -const MAX_BUFFERED_AMOUNT = 8 * 1024 * 1024; - -/** How long `waitConnected` waits before failing with `error.timed-out`. */ -const CONNECT_TIMEOUT_MS = 20_000; - -/** - * How long `close()` keeps the underlying connection alive after the close - * is observed locally, so messages already handed to the transport flush to - * the wire before teardown discards the SCTP send queue. - */ -const CLOSE_DRAIN_MS = 1_000; - -/** The default bound on buffered inbound payload bytes awaiting `receive`. */ -const DEFAULT_MAX_INBOUND_BUFFERED = 8 * 1024 * 1024; - -/** The configured inbound buffer bound; channels capture it at creation. */ -let maxInboundBuffered = DEFAULT_MAX_INBOUND_BUFFERED; - -/** - * Set the per-channel inbound buffer bound, in payload bytes. Ported from - * jco-impl/webrtc.js:78-83; not part of the WIT surface (host policy). - */ -export function setMaxInboundBufferBytes(bytes: number): void { - if (!(Number.isFinite(bytes) && bytes > 0)) { - throw new Error(`invalid inbound buffer bound ${bytes}: expected a positive byte count`); - } - maxInboundBuffered = bytes; -} - -/** Reset the inbound buffer bound to its default (test hook). */ -export function resetMaxInboundBufferBytes(): void { - maxInboundBuffered = DEFAULT_MAX_INBOUND_BUFFERED; -} - -const utf8 = new TextEncoder(); -function utf8ByteLength(text: string): number { - return utf8.encode(text).byteLength; -} - -// --- data-channel-options ---------------------------------------------------- - -/** - * The `data-channel-options` resource: a configuration builder for a data - * channel, mirroring `wasi:http`'s `request-options`. - */ -export class DataChannelOptions { - #label = ""; - #ordered = true; - #maxRetransmits: number | undefined = undefined; - - label(): string { - return this.#label; - } - setLabel(label: string): void { - this.#label = label; - } - - ordered(): boolean { - return this.#ordered; - } - setOrdered(ordered: boolean): void { - this.#ordered = ordered; - } - - maxRetransmits(): number | undefined { - return this.#maxRetransmits; - } - setMaxRetransmits(maxRetransmits: number | undefined): void { - this.#maxRetransmits = maxRetransmits; - } - - /** The `RTCDataChannelInit` these options describe. */ - toInit(): { ordered: boolean; maxRetransmits?: number } { - const init: { ordered: boolean; maxRetransmits?: number } = { ordered: this.#ordered }; - if (this.#maxRetransmits != null) { - init.maxRetransmits = this.#maxRetransmits; - } - return init; - } -} - -// --- peer-connection-config --------------------------------------------------- - -/** - * The `peer-connection-config` resource: a configuration builder with - * fallible setters (`config-error` per contracts/embedder-api.md's - * error model), following `wasi:http`'s `request-options` precedent. - */ -export class PeerConnectionConfig { - #iceServers: IceServer[] = []; - #policy: IceTransportPolicy = "all"; - - iceServers(): IceServer[] { - return this.#iceServers; - } - - setIceServers(servers: IceServer[]): void { - for (const server of servers) { - if (!server.urls.length) { - throw new ComponentException({ kind: "invalid", value: "ice-server has no urls" }); - } - for (const url of server.urls) { - if (!/^(stun|stuns|turn|turns):/.test(url)) { - throw new ComponentException({ - kind: "invalid", - value: `ice-server url ${JSON.stringify(url)} has no stun:/stuns:/turn:/turns: scheme`, - }); - } - } - } - this.#iceServers = servers; - } - - iceTransportPolicy(): IceTransportPolicy { - return this.#policy; - } - - setIceTransportPolicy(policy: IceTransportPolicy): void { - this.#policy = policy; - } - - /** The `RTCConfiguration` these options describe. */ - toConfiguration(): Record { - const configuration: Record = { iceTransportPolicy: this.#policy }; - if (this.#iceServers.length) { - configuration.iceServers = this.#iceServers.map((server) => { - const entry: Record = { urls: server.urls }; - if (server.username) entry.username = server.username; - if (server.credential) entry.credential = server.credential; - return entry; - }); - } - return configuration; - } -} - -// --- inbound message queue (per data-channel) -------------------------------- - -interface Waiter { - resolve: (m: Message) => void; - reject: (e: WebrtcError) => void; -} - -/** - * Per-message inbound queue over a native `RTCDataChannel`, bounded by the - * configured inbound buffer (payload bytes). Ported from - * jco-impl/webrtc.js's `incomingQueue` (1013-1091). - */ -function incomingQueue(channel: { - addEventListener: (type: string, listener: (e: unknown) => void) => void; -}) { - const limit = maxInboundBuffered; - const messages: { message: Message; size: number }[] = []; - const waiters: Waiter[] = []; - let buffered = 0; - let overflowed = false; - let closed = false; - - const push = (message: Message, size: number) => { - const waiter = waiters.shift(); - if (waiter) { - waiter.resolve(message); - } else { - buffered += size; - messages.push({ message, size }); - } - }; - - // deno-lint-ignore no-explicit-any - channel.addEventListener("message", ({ data }: any) => { - if (overflowed) return; - const size = typeof data === "string" ? utf8ByteLength(data) : data.byteLength; - if (buffered + size > limit && !waiters.length) { - overflowed = true; - // deno-lint-ignore no-explicit-any - (channel as any).close(); - return; - } - const message: Message = typeof data === "string" - ? { kind: "string", value: data } - : { kind: "binary", value: new Uint8Array(data) }; - push(message, size); - }); - - const endError = (): WebrtcError => - overflowed ? { kind: "receive-buffer-overflow" } : { kind: "closed" }; - const end = () => { - if (closed) return; - closed = true; - while (waiters.length) waiters.shift()!.reject(endError()); - }; - channel.addEventListener("close", end); - channel.addEventListener("error", end); - - return { - next(): Promise { - if (messages.length) { - const { message, size } = messages.shift()!; - buffered -= size; - return Promise.resolve(message); - } - if (overflowed) return Promise.reject(new ComponentException({ kind: "receive-buffer-overflow" })); - if (closed) return Promise.reject(new ComponentException({ kind: "closed" })); - return new Promise((resolve, reject) => { - waiters.push({ - resolve, - reject: (e) => reject(new ComponentException(e)), - }); - }); - }, - /** Reject every pending waiter with a raw `error` payload (not wrapped). */ - rejectWaiters(error: WebrtcError): void { - while (waiters.length) waiters.shift()!.reject(error); - }, - /** Discard the unread backlog; fail pending and future reads `closed`. */ - discard(): void { - messages.length = 0; - buffered = 0; - closed = true; - while (waiters.length) waiters.shift()!.reject({ kind: "closed" }); - }, - }; -} - -// --- data-channel -------------------------------------------------------------- - -/** - * The `data-channel` resource, implemented over a native `RTCDataChannel`. - * Ported from jco-impl/webrtc.js's `DataChannel` (206-401). - */ -export class DataChannel { - // deno-lint-ignore no-explicit-any - #channel: any; - #incoming: ReturnType; - #streamClaimed = false; - #localClosed = false; - #stateTaken = false; - #statePokes = new Set<() => void>(); - - // deno-lint-ignore no-explicit-any - constructor(channel: any) { - this.#channel = channel; - channel.binaryType = "arraybuffer"; - this.#incoming = incomingQueue(channel); - } - - label(): string { - return this.#channel.label; - } - - async send(message: Message): Promise { - // CONTRACT: the WIT `close` doc requires the close to be "observed - // locally at once" — calls made after `close()` fail `closed` — but - // node-datachannel's `RTCDataChannel.close()` transitions `readyState` - // asynchronously (a `send()` racing right after `close()` can still see - // `"open"`), unlike a synchronous local latch. Gate on the local flag - // first so this port's `close()` is observed synchronously regardless of - // backend timing. - if (this.#localClosed) throw new ComponentException({ kind: "closed" }); - await this.#waitOpen(); - await this.#waitForDrain(); - try { - this.#channel.send(message.value); - } catch { - throw new ComponentException({ kind: "closed" }); - } - } - - async receive(): Promise { - if (this.#localClosed) throw new ComponentException({ kind: "closed" }); - if (this.#streamClaimed) { - throw new ComponentException({ kind: "receiving-via-stream" }); - } - return this.#incoming.next(); - } - - /** - * Send a stream of messages whose payloads are each streamed as bytes. - * The conventions hand the host a `Stream` handle for a guest-provided - * `stream` parameter; a plain `ReadableStream`/`AsyncIterable` is also - * tolerated (contracts/embedder-api.md §"Streams and futures"). - */ - async sendViaStream( - messages: - | Stream - | ReadableStream - | AsyncIterable, - ): Promise { - let sent = 0n; - try { - for await (const item of streamItems(messages)) { - const bytes = await collectByteStream(item.data); - if (bytes.length !== item.length) { - throw { - kind: "other", - value: `stream-message payload was ${bytes.length} bytes but length declared ${item.length}`, - } satisfies WebrtcError; - } - const message: Message = item.kind === "string" - ? { kind: "string", value: new TextDecoder().decode(bytes) } - : { kind: "binary", value: bytes }; - await this.send(message); - sent += 1n; - } - } catch (error) { - const payload: WebrtcError = error instanceof ComponentException - ? (error.payload as WebrtcError) - : (isWebrtcError(error) ? error : { kind: "closed" }); - throw new ComponentException({ error: payload, sent }); - } - } - - /** - * Take over the channel's inbound messages, delivering each as a - * `StreamMessage` whose payload is a `StreamSource`. Once-only per - * the WIT contract. Returns a plain `ReadableStream`, one of the natural - * JS producers the conventions accept where a `stream` result is - * expected — the runtime lowers it; this port never drives a `Store`. - */ - receiveViaStream(): ReadableStream { - if (this.#localClosed) throw new ComponentException({ kind: "closed" }); - if (this.#streamClaimed) { - throw new ComponentException({ kind: "receiving-via-stream" }); - } - this.#streamClaimed = true; - const incoming = this.#incoming; - incoming.rejectWaiters({ kind: "receiving-via-stream" }); - return new ReadableStream({ - async pull(controller) { - let message: Message; - try { - message = await incoming.next(); - } catch { - // The channel closed (or its inbound buffer overflowed): the - // stream simply ends, per the WIT contract. - controller.close(); - return; - } - const bytes = message.kind === "string" - ? new TextEncoder().encode(message.value) - : message.value; - controller.enqueue({ - kind: message.kind, - length: bytes.length, - data: bytesToReadable(bytes) as StreamSource, - }); - }, - }); - } - - /** Resolve once the channel is open, or reject `closed` if it closes. */ - #waitOpen(): Promise { - const channel = this.#channel; - if (channel.readyState === "open") return Promise.resolve(); - if (channel.readyState === "closing" || channel.readyState === "closed") { - return Promise.reject(new ComponentException({ kind: "closed" })); - } - return new Promise((resolve, reject) => { - channel.addEventListener("open", () => resolve(), { once: true }); - channel.addEventListener( - "close", - () => reject(new ComponentException({ kind: "closed" })), - { once: true }, - ); - channel.addEventListener( - "error", - () => reject(new ComponentException({ kind: "closed" })), - { once: true }, - ); - }); - } - - close(): void { - if (this.#localClosed) return; - this.#localClosed = true; - this.#incoming.discard(); - try { - this.#channel.close(); - } catch { - // Already closed. - } - for (const poke of this.#statePokes) poke(); - } - - stateChanges(): ReadableStream { - if (this.#stateTaken) { - return new ReadableStream({ - start(c) { - c.close(); - }, - }); - } - this.#stateTaken = true; - return stateStream( - () => this.#channel.readyState, - (wake) => { - for (const event of ["open", "closing", "close", "error"]) { - this.#channel.addEventListener(event, wake); - } - this.#statePokes.add(wake); - }, - (state) => state === "closed", - ); - } - - [Symbol.dispose](): void { - try { - this.close(); - } catch { - // Already closed. - } - } - - /** Apply backpressure so a fast producer cannot overrun the SCTP buffer. */ - #waitForDrain(): Promise { - const channel = this.#channel; - if (channel.bufferedAmount <= MAX_BUFFERED_AMOUNT) return Promise.resolve(); - return new Promise((resolve) => { - channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2; - const onLow = () => { - channel.removeEventListener("bufferedamountlow", onLow); - resolve(); - }; - channel.addEventListener("bufferedamountlow", onLow); - }); - } -} - -function isWebrtcError(v: unknown): v is WebrtcError { - return typeof v === "object" && v !== null && typeof (v as { kind?: unknown }).kind === "string"; -} - -// --- peer-connection ------------------------------------------------------------ - -/** - * A single WebRTC peer connection driving the full `RTCPeerConnection`-style - * signaling surface: offer/answer, trickle ICE, and in-band data channels. - * Ported from jco-impl/webrtc.js's `PeerConnection` (407-781). - */ -export class PeerConnection { - // deno-lint-ignore no-explicit-any - #pc: any; - #candidates: { stream: ReadableStream; end: () => void }; - #channels: { stream: ReadableStream; end: () => void }; - #everConnected = false; - #closed = false; - #failed = false; - #candidatesTaken = false; - #channelsTaken = false; - #closeHooks = new Set<() => void>(); - // deno-lint-ignore no-explicit-any - #ownedChannels = new Set(); - /** - * The `DataChannel` wrappers over `#ownedChannels`, latched closed by - * `close()`: the wrapper's local-close flag is the synchronous gate the - * WIT contract's "observed locally at once" requires, because a backend - * may transition the native `readyState` asynchronously (node-datachannel - * does — see `DataChannel.send`'s gate comment). - */ - #ownedWrappers = new Set(); - #stateTaken = false; - #statePokes = new Set<() => void>(); - - /** - * Construct a peer connection. `config` is taken by ownership, matching - * the WIT constructor `constructor(config: option)` - * (contracts/embedder-api.md: "the WIT constructor as the JS constructor", - * and — "Constructors are synchronous" — this cannot await). - * - * CONTRACT: resolving `RTCPeerConnection` isomorphically is necessarily - * async (the node-datachannel polyfill is a dynamic `import`). This module - * resolves the backend once via a **top-level await** - * (`resolveRTCPeerConnection()` at the bottom of this file, mirroring the - * reference's own top-level await, jco-impl/webrtc.js:45): ES module - * evaluation does not complete — so no importer's code can run — until a - * module's own top-level await settles, which means every consumer that - * imports this file only ever observes it after the backend is already - * cached. `new PeerConnection(config)` therefore stays synchronous, as the - * WIT constructor requires. `create()` remains available as an async - * convenience for test code that deliberately re-resolves the backend - * mid-run (`resetResolvedBackend()`/`useWerift()`). - */ - constructor(config?: PeerConnectionConfig) { - if (!cachedRTCPeerConnection) { - throw new Error( - "PeerConnection constructed before the RTCPeerConnection backend " + - "resolved — this should be unreachable via normal module import " + - "(top-level await); if resolution failed, the actionable error " + - "was already thrown/logged at module load. Use `await " + - "PeerConnection.create(config)` after `resetResolvedBackend()`.", - ); - } - const ctor = cachedRTCPeerConnection; - this.#pc = new ctor(config ? config.toConfiguration() : undefined); - - const latch = () => { - if (this.#isConnectedNow()) this.#everConnected = true; - if (!this.#failed && this.#isFailedNow()) { - this.#failed = true; - for (const hook of this.#closeHooks) hook(); - this.#closeHooks.clear(); - this.#candidates.end(); - this.#channels.end(); - for (const poke of this.#statePokes) poke(); - } - }; - this.#pc.addEventListener("connectionstatechange", latch); - this.#pc.addEventListener("iceconnectionstatechange", latch); - - this.#candidates = eventStream((push, end) => { - const seen = new Set(); - // deno-lint-ignore no-explicit-any - const pushCandidate = (candidate: string, sdpMid: any, sdpMlineIndex: any) => { - const normalized = candidate.trim().replace(/^a=/, ""); - if (seen.has(normalized)) return; - seen.add(normalized); - push({ candidate: normalized, sdpMid, sdpMlineIndex }); - }; - // deno-lint-ignore no-explicit-any - this.#pc.addEventListener("icecandidate", ({ candidate }: any) => { - if (candidate == null || candidate.candidate === "") { - end(); - return; - } - pushCandidate( - candidate.candidate, - candidate.sdpMid ?? undefined, - candidate.sdpMLineIndex ?? undefined, - ); - }); - this.#pc.addEventListener("icegatheringstatechange", () => { - if (this.#pc.iceGatheringState !== "complete") return; - for (const c of sdpCandidates(this.#pc.localDescription?.sdp)) { - pushCandidate(c.candidate, c.sdpMid, c.sdpMlineIndex); - } - end(); - }); - }); - - this.#channels = eventStream((push) => { - // deno-lint-ignore no-explicit-any - this.#pc.addEventListener("datachannel", ({ channel }: any) => { - this.#ownedChannels.add(channel); - const wrapper = new DataChannel(channel); - this.#ownedWrappers.add(wrapper); - push(wrapper); - }); - }); - } - - /** - * Async convenience factory: resolve the backend (if not already cached) - * then construct. Equivalent to `new PeerConnection(config)` once the - * top-level await above has settled; useful for test code that calls - * `resetResolvedBackend()`/`useWerift()` mid-run. - */ - static async create(config?: PeerConnectionConfig): Promise { - await resolveRTCPeerConnection(); - return new PeerConnection(config); - } - - #requireOpen(): void { - if ( - this.#closed || this.#failed || this.#isFailedNow() || - this.#pc.connectionState === "closed" - ) { - throw new ComponentException({ kind: "closed" }); - } - } - - #isConnectedNow(): boolean { - return ( - this.#pc.connectionState === "connected" || - this.#pc.iceConnectionState === "connected" || - this.#pc.iceConnectionState === "completed" - ); - } - - #isFailedNow(): boolean { - return this.#pc.connectionState === "failed" || this.#pc.iceConnectionState === "failed"; - } - - createDataChannel(options: DataChannelOptions): DataChannel { - this.#requireOpen(); - try { - const channel = this.#pc.createDataChannel(options.label(), options.toInit()); - this.#ownedChannels.add(channel); - const wrapper = new DataChannel(channel); - this.#ownedWrappers.add(wrapper); - return wrapper; - } catch (err) { - throw new ComponentException({ kind: "other", value: String(err) }); - } - } - - incomingDataChannels(): ReadableStream { - if (this.#channelsTaken) { - return new ReadableStream({ - start(c) { - c.close(); - }, - }); - } - this.#channelsTaken = true; - return this.#channels.stream; - } - - async createOffer(): Promise { - this.#requireOpen(); - try { - if (offerNeedsChannel && this.#ownedChannels.size === 0) { - this.#pc.createDataChannel("", { negotiated: true, id: 1023 }); - } - const offer = await this.#pc.createOffer(); - return { kind: "offer", sdp: offer.sdp }; - } catch (err) { - throw new ComponentException({ kind: "other", value: String(err) }); - } - } - - async createAnswer(): Promise { - this.#requireOpen(); - try { - const answer = await this.#pc.createAnswer(); - return { kind: "answer", sdp: answer.sdp }; - } catch (err) { - throw new ComponentException({ kind: "other", value: String(err) }); - } - } - - async setLocalDescription(description: SessionDescription): Promise { - this.#requireOpen(); - try { - await this.#pc.setLocalDescription({ type: description.kind, sdp: description.sdp }); - } catch (err) { - throw new ComponentException({ kind: "invalid-signaling", value: String(err) }); - } - } - - async setRemoteDescription(description: SessionDescription): Promise { - this.#requireOpen(); - try { - await this.#pc.setRemoteDescription({ type: description.kind, sdp: description.sdp }); - } catch (err) { - throw new ComponentException({ kind: "invalid-signaling", value: String(err) }); - } - } - - localIceCandidates(): ReadableStream { - if (this.#candidatesTaken) { - return new ReadableStream({ - start(c) { - c.close(); - }, - }); - } - this.#candidatesTaken = true; - return this.#candidates.stream; - } - - async addIceCandidate(candidate: IceCandidate): Promise { - this.#requireOpen(); - try { - await this.#pc.addIceCandidate({ - candidate: candidate.candidate, - sdpMid: candidate.sdpMid ?? null, - sdpMLineIndex: candidate.sdpMlineIndex ?? null, - }); - } catch (err) { - throw new ComponentException({ kind: "invalid-signaling", value: String(err) }); - } - } - - stateChanges(): ReadableStream { - if (this.#stateTaken) { - return new ReadableStream({ - start(c) { - c.close(); - }, - }); - } - this.#stateTaken = true; - return stateStream( - () => { - if (this.#closed) return "closed"; - if (this.#failed) return "failed"; - return this.#pc.connectionState; - }, - (wake) => { - this.#pc.addEventListener("connectionstatechange", wake); - this.#pc.addEventListener("iceconnectionstatechange", wake); - this.#statePokes.add(wake); - }, - (state) => state === "failed" || state === "closed", - ); - } - - async waitConnected(): Promise { - const pc = this.#pc; - const isFailed = () => this.#isFailedNow() || pc.connectionState === "closed"; - - if (this.#isConnectedNow()) this.#everConnected = true; - if (this.#everConnected) return; - if (this.#closed || isFailed()) throw new ComponentException({ kind: "closed" }); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new ComponentException({ kind: "timed-out" })); - }, CONNECT_TIMEOUT_MS); - const check = () => { - if (this.#isConnectedNow()) { - this.#everConnected = true; - cleanup(); - resolve(); - } else if (isFailed()) { - cleanup(); - reject(new ComponentException({ kind: "closed" })); - } - }; - const onClose = () => { - cleanup(); - reject(new ComponentException({ kind: "closed" })); - }; - const cleanup = () => { - clearTimeout(timer); - this.#closeHooks.delete(onClose); - pc.removeEventListener("connectionstatechange", check); - pc.removeEventListener("iceconnectionstatechange", check); - }; - this.#closeHooks.add(onClose); - pc.addEventListener("connectionstatechange", check); - pc.addEventListener("iceconnectionstatechange", check); - }); - } - - close(): void { - if (this.#closed) return; - this.#closed = true; - for (const hook of this.#closeHooks) hook(); - this.#closeHooks.clear(); - this.#candidates.end(); - this.#channels.end(); - for (const poke of this.#statePokes) poke(); - // Close the owned channels through their wrappers, so the close is - // observed locally at once (the wrapper latches its local-close flag - // and closes the native channel; `DataChannel.close` is idempotent). - // Gating on the native `readyState` alone is not enough: a backend may - // transition it asynchronously, leaving a post-close `send` a window - // in which it still sees `"open"`. - for (const wrapper of this.#ownedWrappers) { - try { - wrapper.close(); - } catch { - // Already closed. - } - } - const deadline = Date.now() + CLOSE_DRAIN_MS; - const drained = () => - // deno-lint-ignore no-explicit-any - [...this.#ownedChannels].every((channel: any) => channel.bufferedAmount === 0); - const tick = setInterval(() => { - if (drained() || Date.now() >= deadline) { - clearInterval(tick); - this.#pc.close(); - } - }, 10); - // deno-lint-ignore no-explicit-any - (tick as any).unref?.(); - } - - [Symbol.dispose](): void { - try { - this.close(); - } catch { - // Already closed. - } - } -} - -// --- helpers ------------------------------------------------------------------- - -/** - * Extract the ICE candidates from an SDP description as - * `{ candidate, sdpMid, sdpMlineIndex }` records in the W3C trickle shape. - * Ported from jco-impl/webrtc.js:834-854. - */ -function sdpCandidates( - sdp: string | undefined, -): { candidate: string; sdpMid: string | undefined; sdpMlineIndex: number | undefined }[] { - if (!sdp) return []; - const out: { candidate: string; sdpMid: string | undefined; sdpMlineIndex: number | undefined }[] = []; - let sdpMid: string | undefined; - let sdpMlineIndex = -1; - for (const line of sdp.split(/\r?\n/)) { - if (line.startsWith("m=")) { - sdpMlineIndex += 1; - sdpMid = undefined; - } else if (line.startsWith("a=mid:")) { - sdpMid = line.slice("a=mid:".length).trim(); - } else if (line.startsWith("a=candidate:")) { - out.push({ - candidate: line.slice("a=".length).trim(), - sdpMid, - sdpMlineIndex: sdpMlineIndex >= 0 ? sdpMlineIndex : undefined, - }); - } - } - return out; -} - -/** - * Iterate a guest-provided WIT stream. The conventions hand the host a - * `Stream` handle whose async iterator yields `Chunk` — an *array* of - * elements for a non-`u8` element type — so a batched read is flattened - * here. A web `ReadableStream`/plain `AsyncIterable` is also tolerated. - * Ported from `ports/websocket/src/websocket.ts`'s `streamItems`. - */ -async function* streamItems( - input: - | Stream - | ReadableStream - | AsyncIterable, -): AsyncGenerator { - if (input instanceof ReadableStream) { - const reader = input.getReader(); - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - yield value; - } - } finally { - reader.releaseLock(); - } - return; - } - for await (const value of input as AsyncIterable) { - // A batched read yields an array of elements. - if (Array.isArray(value)) { - yield* value as LiftedStreamMessage[]; - } else { - yield value as LiftedStreamMessage; - } - } -} - -/** - * Collect every byte of a `stream` into one `Uint8Array`. Accepts the - * runtime's `Stream` handle (`read(max)`, empty chunk = end — see - * contracts/embedder-api.md §"Streams and futures"), a `ReadableStream`, or - * a plain `AsyncIterable`. - */ -async function collectByteStream( - stream: Stream | ReadableStream | AsyncIterable, -): Promise { - const chunks: Uint8Array[] = []; - let total = 0; - const push = (value: unknown) => { - if (value === undefined || value === null) return; - const chunk = toByteChunk(value); - if (chunk.length) { - chunks.push(chunk); - total += chunk.length; - } - }; - if (typeof ReadableStream !== "undefined" && stream instanceof ReadableStream) { - const reader = stream.getReader(); - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - push(value); - } - } finally { - reader.releaseLock(); - } - } else if (stream instanceof Stream) { - const READ_BATCH = 65536; - for (;;) { - const chunk = await stream.read(READ_BATCH); - if ((chunk as Uint8Array).length === 0) break; - push(chunk); - } - } else { - for await (const value of stream as AsyncIterable) { - push(value); - } - } - const out = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out; -} - -/** - * Coerce one chunk of a WIT byte stream (a number, an array of numbers, or - * a typed array, depending on how the runtime batched the read) to a - * `Uint8Array`. - */ -function toByteChunk(value: unknown): Uint8Array { - if (typeof value === "number") return Uint8Array.of(value); - if (value instanceof Uint8Array) return value; - return Uint8Array.from(value as ArrayLike); -} - -/** A single-chunk byte `ReadableStream` over `bytes`. */ -function bytesToReadable(bytes: Uint8Array): ReadableStream { - return new ReadableStream({ - start(controller) { - if (bytes.length) controller.enqueue(bytes); - controller.close(); - }, - }); -} - -/** - * A `ReadableStream` fed by an event source. `setup(push, end)` wires the - * source to `push` each value and `end` to close the stream; values pushed - * before the stream starts pulling are buffered. Ported verbatim from the - * jco-impl reference's `eventStream` (jco-impl/webrtc.js:788-818). - */ -function eventStream( - setup: (push: (item: T) => void, end: () => void) => void, -): { stream: ReadableStream; end: () => void } { - let controller: ReadableStreamDefaultController | undefined; - let ended = false; - const buffer: T[] = []; - const stream = new ReadableStream({ - start(c) { - controller = c; - for (const item of buffer) c.enqueue(item); - buffer.length = 0; - if (ended) c.close(); - }, - }); - const push = (item: T) => { - if (ended) return; - if (controller) controller.enqueue(item); - else buffer.push(item); - }; - const end = () => { - if (ended) return; - ended = true; - if (controller) { - try { - controller.close(); - } catch { - // Already closed. - } - } - }; - setup(push, end); - return { stream, end }; -} - -/** - * A pull-based coalescing state watch backing the `state-changes` streams: - * each element is `current()` at the time it is produced (the first - * element reflects the state at the first read), consecutive elements are - * distinct, and the stream closes after a terminal state. Ported from the - * jco-impl reference's `stateStream` (jco-impl/webrtc.js:978-1011). - */ -function stateStream( - current: () => S, - subscribe: (wake: () => void) => void, - isTerminal: (state: S) => boolean, -): ReadableStream { - let delivered: S | undefined; - let hasDelivered = false; - let notify: (() => void) | null = null; - subscribe(() => { - if (notify) { - const wake = notify; - notify = null; - wake(); - } - }); - return new ReadableStream({ - async pull(controller) { - for (;;) { - const woken = new Promise((resolve) => { - notify = resolve; - }); - const state = current(); - if (!hasDelivered || state !== delivered) { - hasDelivered = true; - delivered = state; - controller.enqueue(state); - if (isTerminal(state)) controller.close(); - return; - } - if (isTerminal(state)) { - controller.close(); - return; - } - await woken; - } - }, - }); -} - -// Eagerly resolve the backend at module load via a genuine **top-level -// await**, mirroring the reference's own top-level await -// (jco-impl/webrtc.js:45): ES module evaluation does not complete for any -// importer of this file until this await settles, so `new PeerConnection` -// stays synchronous once anyone can actually reach it — see the doc comment -// on the constructor. Failures are swallowed here rather than left to -// propagate out of module evaluation (which would break every consumer of -// this module in environments without WebRTC at all, including ones that -// only want e.g. the pure structural types); `PeerConnection.create()` -// re-attempts resolution and surfaces the actionable error to its caller. -await resolveRTCPeerConnection().catch(() => { - cachedRTCPeerConnection = undefined; -}); - -// --- module wiring (contracts/embedder-api.md §"Module wiring and instantiation") -- - -/** The exact WIT interface id this port implements. */ -export const CONNECTIONS_INTERFACE = "polymorph:webrtc-datachannels/connections@0.1.0"; - -/** - * The interface-shaped aggregate export: "a module's named export, camelCase - * of the interface short-name, provides that interface." - */ -export const connections = { DataChannelOptions, PeerConnectionConfig, DataChannel, PeerConnection }; - -/** - * The imports-record fragment for `instantiate`: - * `{ "polymorph:webrtc-datachannels/connections@0.1.0": { ... } }`. - * Registered at the interface's exact version (only one exists) — the - * resolver derives the `@0.1` track alternate automatically. - */ -export function webrtcImports(): Record { - return { [CONNECTIONS_INTERFACE]: connections }; -} diff --git a/ports/webrtc/webrtc.test.ts b/ports/webrtc/webrtc.test.ts deleted file mode 100644 index 8dcd626..0000000 --- a/ports/webrtc/webrtc.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -// In-process loopback tests for the `ports/webrtc` host module: two -// instantiations of the port's `PeerConnection`/`DataChannel` classes wired -// directly to each other (no signaling server), following the -// trickle-ICE-buffering discipline proven by the C0 capability probe -// (`tools/probes/webrtc-deno/`, retired 2026-08-14 — its questions are -// answered in docs/consumers.md; source via git history). -// -// Sanitizer note: `node-datachannel` keeps background native threads alive -// across the whole process (proven by the probe, which calls `Deno.exit` -// after an explicit `.cleanup()`). A `deno test` process cannot exit -// mid-suite, so every test below disables `sanitizeResources`/`sanitizeOps` — -// the leaked timers/ops belong to node-datachannel's native worker pool, not -// to unclosed resources this test forgot to close (every test does close its -// peer connections). `node-datachannel`'s own `cleanup()` is called once at -// the end of the whole suite (see the final `Deno.test` below), matching the -// probe's discipline; per-test cleanup would tear down the shared native -// context out from under any test that runs after it. - -import { assert, assertEquals, assertRejects } from "jsr:@std/assert@1"; -import { - DataChannel, - DataChannelOptions, - PeerConnection, - resetMaxInboundBufferBytes, - setMaxInboundBufferBytes, -} from "./src/webrtc.ts"; -import { ComponentException } from "@deltic/runtime/embedder"; -import type { IceCandidate, Message, WebrtcError } from "./src/types.ts"; - -const NO_SANITIZE = { sanitizeResources: false, sanitizeOps: false }; - -/** - * Wire two freshly constructed `PeerConnection`s through a full offer/answer - * + trickle-ICE exchange, in-process (no network signaling server) — - * ported wiring discipline from the retired probe's loopback - * (buffer candidates until the peer has its remote description). - */ -async function connectPair(): Promise<{ a: PeerConnection; b: PeerConnection }> { - const a = await PeerConnection.create(); - const b = await PeerConnection.create(); - - // Candidates start flowing as soon as a local description is set — on a - // slow machine that is BEFORE the counterpart has its remote description, - // and an addIceCandidate delivered that early is rejected - // (invalid-signaling) and lost (the stream delivers each candidate once). - // Hold each pump until its receiving peer is ready. - let aHasRemote!: () => void; - let bHasRemote!: () => void; - const ready = { - a: new Promise((r) => (aHasRemote = r)), - b: new Promise((r) => (bHasRemote = r)), - }; - const pumpCandidates = ( - from: PeerConnection, - to: PeerConnection, - toReady: Promise, - ) => { - (async () => { - // `localIceCandidates()` returns a plain `ReadableStream` - // (one candidate per element — see src/webrtc.ts's - // "Module wiring"/streams note), not a batched `Stream` handle. - for await (const candidate of from.localIceCandidates()) { - await toReady; - try { - await to.addIceCandidate(candidate); - } catch { - // Connection may have moved on/closed; ignore stray trickles. - } - } - })(); - }; - pumpCandidates(a, b, ready.b); - pumpCandidates(b, a, ready.a); - - const offer = await a.createOffer(); - await a.setLocalDescription(offer); - await b.setRemoteDescription(offer); - bHasRemote(); - const answer = await b.createAnswer(); - await b.setLocalDescription(answer); - await a.setRemoteDescription(answer); - aHasRemote(); - - await Promise.all([a.waitConnected(), b.waitConnected()]); - return { a, b }; -} - -/** The first data channel `pc` receives via `incomingDataChannels`. */ -async function firstIncoming(pc: PeerConnection): Promise { - for await (const ch of pc.incomingDataChannels()) return ch; - throw new Error("incomingDataChannels ended with no channel"); -} - -Deno.test("loopback: text echo both directions", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - try { - const options = new DataChannelOptions(); - options.setLabel("chat"); - const chA = a.createDataChannel(options); - const chB = await firstIncoming(b); - - await chA.send({ kind: "string", value: "hello from a" }); - const gotAtB = await chB.receive(); - assertEquals(gotAtB, { kind: "string", value: "hello from a" }); - - await chB.send({ kind: "string", value: "hello from b" }); - const gotAtA = await chA.receive(); - assertEquals(gotAtA, { kind: "string", value: "hello from b" }); - } finally { - a.close(); - b.close(); - } -}); - -Deno.test("loopback: binary echo + message-boundary preservation", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - try { - const chA = a.createDataChannel(new DataChannelOptions()); - const chB = await firstIncoming(b); - - const msg1 = new Uint8Array([1, 2, 3]); - const msg2 = new Uint8Array([4, 5]); - await chA.send({ kind: "binary", value: msg1 }); - await chA.send({ kind: "binary", value: msg2 }); - - const got1 = await chB.receive(); - const got2 = await chB.receive(); - assertEquals(got1, { kind: "binary", value: msg1 }); - assertEquals(got2, { kind: "binary", value: msg2 }); - } finally { - a.close(); - b.close(); - } -}); - -Deno.test("loopback: unordered/maxRetransmits options accepted", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - try { - const options = new DataChannelOptions(); - options.setOrdered(false); - options.setMaxRetransmits(3); - assertEquals(options.ordered(), false); - assertEquals(options.maxRetransmits(), 3); - - const chA = a.createDataChannel(options); - const chB = await firstIncoming(b); - await chA.send({ kind: "string", value: "unordered ok" }); - assertEquals(await chB.receive(), { kind: "string", value: "unordered ok" }); - } finally { - a.close(); - b.close(); - } -}); - -Deno.test("loopback: receive-via-stream consumes a burst", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - try { - const chA = a.createDataChannel(new DataChannelOptions()); - const chB = await firstIncoming(b); - - const received: Message[] = []; - const streamDone = (async () => { - const stream = chB.receiveViaStream(); - for await (const sm of stream) { - const bytes = await collectU8(sm.data as unknown as AsyncIterable); - received.push( - sm.kind === "string" - ? { kind: "string", value: new TextDecoder().decode(bytes) } - : { kind: "binary", value: bytes }, - ); - if (received.length === 3) return; - } - })(); - - for (let i = 0; i < 3; i++) { - await chA.send({ kind: "string", value: `msg-${i}` }); - } - await streamDone; - assertEquals(received, [ - { kind: "string", value: "msg-0" }, - { kind: "string", value: "msg-1" }, - { kind: "string", value: "msg-2" }, - ]); - } finally { - a.close(); - b.close(); - } -}); - -async function collectU8(stream: AsyncIterable): Promise { - const chunks: Uint8Array[] = []; - let total = 0; - for await (const chunk of stream) { - chunks.push(chunk); - total += chunk.length; - } - const out = new Uint8Array(total); - let off = 0; - for (const c of chunks) { - out.set(c, off); - off += c.length; - } - return out; -} - -Deno.test("loopback: single-use violation -> receiving-via-stream error", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - try { - const chA = a.createDataChannel(new DataChannelOptions()); - const chB = await firstIncoming(b); - void chA; // keep `a`'s channel referenced for symmetry/documentation - - chB.receiveViaStream(); - const err = await assertRejects(() => chB.receive(), ComponentException); - assertEquals((err as ComponentException).payload, { kind: "receiving-via-stream" }); - - // A second `receiveViaStream` call after the first also violates the - // once-only rule (thrown synchronously, per the WIT contract). - let threw: unknown; - try { - chB.receiveViaStream(); - } catch (e) { - threw = e; - } - assert(threw instanceof ComponentException); - assertEquals((threw as ComponentException).payload, { kind: "receiving-via-stream" }); - } finally { - a.close(); - b.close(); - } -}); - -Deno.test("loopback: inbound-buffer overflow -> overflow-close semantics", NO_SANITIZE, async () => { - setMaxInboundBufferBytes(16); // small bound: a handful of short messages overflow it - try { - const { a, b } = await connectPair(); - try { - const chA = a.createDataChannel(new DataChannelOptions()); - const chB = await firstIncoming(b); - - // Send enough payload bytes to exceed the 16-byte bound; the receiver - // never calls `receive()` while these arrive, so they all buffer up - // and the overflow-close fires on the sender or receiver's channel. - for (let i = 0; i < 20; i++) { - try { - await chA.send({ kind: "string", value: `0123456789-${i}` }); - } catch { - break; // sender side observed the close once b's channel closed. - } - } - - // Drain whatever buffered before the overflow, then expect the - // terminal `receive-buffer-overflow` error (per the WIT `data-channel` - // resource doc: "messages buffered before the overflow remain - // receivable, after which `receive` fails with - // `error.receive-buffer-overflow`"). - let overflowed = false; - for (let i = 0; i < 20; i++) { - try { - await chB.receive(); - } catch (e) { - assert(e instanceof ComponentException); - assertEquals((e as ComponentException).payload, { - kind: "receive-buffer-overflow", - }); - overflowed = true; - break; - } - } - assert(overflowed, "expected receive-buffer-overflow after draining the backlog"); - } finally { - a.close(); - b.close(); - } - } finally { - resetMaxInboundBufferBytes(); - } -}); - -Deno.test("loopback: close propagation + post-close error cases", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - const chA = a.createDataChannel(new DataChannelOptions()); - const chB = await firstIncoming(b); - - chA.close(); - const err = await assertRejects(() => chA.send({ kind: "string", value: "x" }), ComponentException); - assertEquals((err as ComponentException).payload, { kind: "closed" }); - - // The peer observes the remote close too (eventually `receive` fails). - let sawClosed = false; - for (let i = 0; i < 50; i++) { - try { - await chB.receive(); - } catch (e) { - assert(e instanceof ComponentException); - sawClosed = true; - break; - } - } - assert(sawClosed, "expected the peer's receive() to observe the remote close"); - - a.close(); - b.close(); - const connErr = await assertRejects(() => a.createOffer(), ComponentException); - assertEquals((connErr as ComponentException).payload, { kind: "closed" }); -}); - -Deno.test( - "loopback: peer-connection close latches its channels synchronously", - NO_SANITIZE, - async () => { - const { a, b } = await connectPair(); - const chA = a.createDataChannel(new DataChannelOptions()); - const chB = await firstIncoming(b); - - // Closing the PEER CONNECTION closes its owned channels with the close - // observed locally at once (the WIT contract): the very first send() - // after close() fails `closed`, on the locally created channel and on - // the incoming (remote-created) one alike — regardless of how lazily - // the backend transitions the native readyState. - a.close(); - const errA = await assertRejects( - () => chA.send({ kind: "string", value: "after-close" }), - ComponentException, - ); - assertEquals((errA as ComponentException).payload, { kind: "closed" }); - - b.close(); - const errB = await assertRejects( - () => chB.send({ kind: "string", value: "after-close" }), - ComponentException, - ); - assertEquals((errB as ComponentException).payload, { kind: "closed" }); - }, -); - -Deno.test("loopback: wait-connected resolves and is latched", NO_SANITIZE, async () => { - const { a, b } = await connectPair(); - try { - // Already connected; awaiting again resolves immediately (latched). - await a.waitConnected(); - await b.waitConnected(); - } finally { - a.close(); - b.close(); - // Latch survives close, per the WIT contract. - await a.waitConnected(); - await b.waitConnected(); - } -}); - -// Run node-datachannel's cleanup once, after every test has finished, per -// the probe's discipline (probe.mjs:74/89-90) — it tears down the shared -// native ICE/DTLS/SCTP worker context so the process can exit. This must be -// the LAST registered test so it runs after all the loopback tests above. -Deno.test({ - name: "cleanup: node-datachannel native workers", - ...NO_SANITIZE, - fn: async () => { - try { - const nodeDatachannel = await import("node-datachannel"); - nodeDatachannel.cleanup?.(); - // Give libdatachannel's poll/worker threads time to join before the - // test runner tears the isolate down: an exit that races the joins - // can die in native teardown (SIGSEGV) after every test has already - // passed. (A hard process exit right after cleanup() is fine — this - // settle exists for harnesses that keep the runtime alive.) - await new Promise((resolve) => setTimeout(resolve, 1000)); - } catch { - // Not resolved to node-datachannel in this run (e.g. werift-forced - // test environment, or a browser-like global) — nothing to clean up. - } - }, -}); diff --git a/ports/websocket/README.md b/ports/websocket/README.md deleted file mode 100644 index 42f9737..0000000 --- a/ports/websocket/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# `ports/websocket` — the `polymorph:websocket` host module - -The consumer host module for `polymorph:websocket/connections@0.1.0`, -ported to the deltic embedder conventions -(`contracts/embedder-api.md`), plus the runner that executes the -consumer's **real** conformance suite under deltic. - -This package is deliberately **not** a member of the root Deno workspace -(like `tools/smoke-c0`): it imports the runtime, `wasi-shims` and -`ct-runner` by relative path. The "not a member of the workspace" warning -Deno prints is benign. - -## Tasks - -| task | what it does | -|---|---| -| `deno task check` | type-check `src`, `tests`, `conformance` | -| `deno task test` | unit tests against a local Deno echo server | -| `deno task conformance` | build+spawn the consumer's `conformance-echod`, then run all 54 suite cases through `ct-runner` | - -## Provenance - -- Contract: `polymorph-websocket/wit/websocket.wit`. -- Behavioral reference: `polymorph-websocket/js/jco/websocket.js` — the - browser-first host the consumer's suite asserts. `src/websocket.ts` - preserves its logic line-for-line (cited as `websocket.js:LINE`) and - translates only the conventions: - bare-payload throws → `ComponentException`, jco `Stream` → `Stream` / - `ReadableStream`, `--map` module wiring → `websocketImports()`, - module-level setters → `configure()` **plus** the compatible - `setMaxInboundBufferBytes` / `setConnectTimeoutMs` / - `setCloseTimeoutMs` spellings. -- Runner reference: `conformance/driver-ct/jco/run-node.mjs` — the same - bounds (256 KiB inbound buffer, 5 s connect, 3 s close), the same - `WS_CONFORMANCE_*` environment, the same echod spawn contract. - -## Behavioral delta vs. `websocket.js` - -Exactly one, and it is a **runtime** difference, not a design choice: - -- **Abnormal-closure close code.** Browsers and Node deliver - `CloseEvent.code === 1006` when the peer drops TCP with no close frame; - **Deno delivers `0`**. Both mean "no close frame was received", so the - port treats `{0, 1006, 1015}` as the synthesized set that maps to - *no* `close-info`. `1005` is deliberately excluded — it is the - legitimate observation of a code-less close frame, which the suite - asserts. See `#settleClosed` for the full note. - -Everything else — buffered-amount polling, the connect/close bounds, the -overflow-close rule, the receive-via-stream single-use rule — behaves -identically on Deno. - -## Consumer tree hygiene - -Nothing here writes into `polymorph-websocket`. The `conformance-echod` -build redirects `CARGO_TARGET_DIR` to `/tmp/opencode/c3-ws-target` and -passes `--locked`; the suite artifact and the test PKI are read-only -inputs. diff --git a/ports/websocket/conformance/.gitignore b/ports/websocket/conformance/.gitignore deleted file mode 100644 index 5dd0a54..0000000 --- a/ports/websocket/conformance/.gitignore +++ /dev/null @@ -1 +0,0 @@ -results.jsonl diff --git a/ports/websocket/conformance/run.ts b/ports/websocket/conformance/run.ts deleted file mode 100644 index fea16b7..0000000 --- a/ports/websocket/conformance/run.ts +++ /dev/null @@ -1,232 +0,0 @@ -// The flagship gate: execute the consumer's REAL conformance suite -// (`polymorph-websocket/conformance/guest-ct`) under deltic, with -// this port supplying `polymorph:websocket/connections@0.1.0` and -// `wasi-shims` supplying WASI. -// -// deno task conformance [--only SUBSTRING] [--jspi] -// -// `DENO_CERT` must name the suite's committed test CA so the three -// `websocket/tls/*` cases can complete their handshake; `deno task -// conformance` supplies it. NO cases are excluded: the TLS leg runs -// headlessly under Deno exactly as the ws: leg does. -// -// This is the deltic analogue of the consumer's own jco Node leg, -// `conformance/driver-ct/jco/run-node.mjs`, and mirrors it exactly: -// -// run-node.mjs | this runner -// ------------------------------------------+--------------------------- -// `connections.setMaxInboundBufferBytes(…)` | `configure({ … })`, same values -// `spawnEchod(bin)` scraping LISTENING | `spawnEchod()`, same scrape -// `unreachableUrl()` (bind port 0, release) | same -// `env = [[WS_CONFORMANCE_*, …]]` | `wasiShims({ cli: { env } })` -// `runSuite(...)` (component-test-js) | `runSuite(...)` (ct-runner) -// `NODE_EXTRA_CA_CERTS=…/tls/ca.pem` | `DENO_CERT=…/tls/ca.pem` -// -// NOTHING here writes into the consumer tree: the echod build redirects -// `CARGO_TARGET_DIR`, and results are written under this directory. - -import { Translator } from "../../../runtime/src/shim/mod.ts"; -import type { ComponentArtifacts } from "../../../runtime/src/embedder/mod.ts"; -import { runSuite } from "../../../ct-runner/src/mod.ts"; -import { wasiShims } from "../../../wasi-shims/src/mod.ts"; -import { configure, websocketImports } from "../src/websocket.ts"; - -const CE_ROOT = new URL("../../../", import.meta.url).pathname; -const CONSUMER = "/home/lmartin/p/polymorph/polymorph-websocket"; -const ECHOD_TARGET = "/tmp/opencode/c3-ws-target"; -const ECHOD_BIN = `${ECHOD_TARGET}/debug/conformance-echod`; -/** The suite artifact: the BARE suite — websocket still imported. This is - * the exact component the jco leg transpiles (jco/package.json's - * `transpile` script names it), i.e. the pre-transpile input; the sibling - * `composed/` artifact has the provider plugged in-guest and would exercise - * no host module at all. */ -const SUITE_WASM = - `${CONSUMER}/target/wasm32-wasip2/release/conformance_guest_ct.wasm`; -const CA_PEM = `${CONSUMER}/conformance/server/tls/ca.pem`; - -// The suite bounds, matching run-node.mjs:29-32 (which in turn matches the -// wasmtime leg). Connections capture them at connect, so configuring the -// module once covers every case. -const MAX_INBOUND_BUFFER_BYTES = 256 * 1024; -const CONNECT_TIMEOUT_MS = 5000; -const CLOSE_TIMEOUT_MS = 3000; - -/** harness.mjs:33's per-case wall bound. */ -const CASE_TIMEOUT_MS = 60_000; - -interface Cli { - only?: string; - jspi: boolean; - out: string; - target: string; -} - -function parseArgs(argv: string[]): Cli { - const cli: Cli = { - jspi: false, - out: new URL("./results.jsonl", import.meta.url).pathname, - target: "deltic", - }; - for (let i = 0; i < argv.length; i++) { - switch (argv[i]) { - case "--only": - cli.only = argv[++i]; - break; - case "--jspi": - cli.jspi = true; - break; - case "--out": - cli.out = argv[++i]; - break; - case "--target": - cli.target = argv[++i]; - break; - default: - throw new Error(`unknown argument ${argv[i]}`); - } - } - return cli; -} - -/** `spawnEchod` (conformance/server/echod.mjs:13), ported: start the binary - * and scrape its one `LISTENING ` line. */ -async function spawnEchod(): Promise<{ - base: string; - tlsBase: string; - shutdown: () => void; -}> { - const child = new Deno.Command(ECHOD_BIN, { - stdout: "piped", - stderr: "inherit", - }).spawn(); - const reader = child.stdout.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - const deadline = setTimeout(() => { - throw new Error("echo server did not report a URL in time"); - }, 10_000); - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) throw new Error("echo server exited before reporting a URL"); - buffer += decoder.decode(value, { stream: true }); - const m = /LISTENING (ws:\/\/\S+) (wss:\/\/\S+)/.exec(buffer); - if (m) { - return { - base: m[1].trim(), - tlsBase: m[2].trim(), - shutdown: () => { - try { - child.kill("SIGTERM"); - } catch { /* already gone */ } - reader.cancel().catch(() => {}); - }, - }; - } - } - } finally { - clearTimeout(deadline); - reader.releaseLock(); - } -} - -/** `unreachableUrl` (echod.mjs:53), ported: a loopback `ws:` URL whose - * connect attempt should be refused — a port just bound and released. */ -function unreachableUrl(): string { - const l = Deno.listen({ hostname: "127.0.0.1", port: 0 }); - const { port } = l.addr as Deno.NetAddr; - l.close(); - return `ws://127.0.0.1:${port}/echo`; -} - -async function ensureEchod(): Promise { - try { - const st = await Deno.stat(ECHOD_BIN); - if (st.isFile) return; - } catch { /* build it */ } - console.error(`building conformance-echod into ${ECHOD_TARGET} …`); - const out = await new Deno.Command("cargo", { - args: [ - "build", - "--locked", - "--manifest-path", - `${CONSUMER}/Cargo.toml`, - "-p", - "conformance-echod", - ], - env: { CARGO_TARGET_DIR: ECHOD_TARGET }, - stdout: "inherit", - stderr: "inherit", - }).output(); - if (!out.success) throw new Error("conformance-echod build failed"); -} - -async function loadArtifacts(): Promise { - const shim = `${CE_ROOT}target/wasm32-unknown-unknown/release/translator_shim.wasm`; - const translator = await Translator.create(await Deno.readFile(shim)); - const componentBytes = await Deno.readFile(SUITE_WASM); - const { plan, adapters } = translator.translate(componentBytes); - return { plan, componentBytes, adapters }; -} - -async function main() { - const cli = parseArgs(Deno.args); - - if (Deno.env.get("DENO_CERT") === undefined) { - console.error( - `warning: DENO_CERT is unset — the suite's committed test PKI is not ` + - `trusted, so the three websocket/tls/* cases will fail their ` + - `connect. Re-run with DENO_CERT=${CA_PEM} (the Deno analogue of ` + - `run-node.mjs's NODE_EXTRA_CA_CERTS, justfile:135).`, - ); - } - - configure({ - maxInboundBufferBytes: MAX_INBOUND_BUFFER_BYTES, - connectTimeoutMs: CONNECT_TIMEOUT_MS, - closeTimeoutMs: CLOSE_TIMEOUT_MS, - }); - - await ensureEchod(); - const echod = await spawnEchod(); - console.error(`echo server ready at ${echod.base} (tls: ${echod.tlsBase})`); - - const env: Record = { - WS_CONFORMANCE_SERVER_URL: echod.base, - WS_CONFORMANCE_TLS_SERVER_URL: echod.tlsBase, - WS_CONFORMANCE_UNREACHABLE_URL: unreachableUrl(), - WS_CONFORMANCE_MAX_INBOUND_BUFFER_BYTES: String(MAX_INBOUND_BUFFER_BYTES), - }; - - const artifacts = await loadArtifacts(); - const imports = { - ...wasiShims({ cli: { env, passthrough: false } }), - ...websocketImports(), - }; - - const lines: string[] = []; - const started = performance.now(); - try { - const counts = await runSuite(artifacts, { - imports, - target: cli.target, - suiteName: "conformance_guest_ct", - only: cli.only, - caseTimeoutMs: CASE_TIMEOUT_MS, - jspi: cli.jspi, - emit: (line) => lines.push(line), - log: (msg) => console.error(msg), - }); - await Deno.writeTextFile(cli.out, lines.join("\n") + "\n"); - console.error( - `\n${counts.passed} passed | ${counts.failed} failed | ${counts.skipped} skipped ` + - `(${counts.total} total) in ${((performance.now() - started) / 1000).toFixed(1)}s ` + - `-> ${cli.out}`, - ); - if (counts.failed > 0) Deno.exitCode = 1; - } finally { - echod.shutdown(); - } -} - -if (import.meta.main) await main(); diff --git a/ports/websocket/deno.json b/ports/websocket/deno.json deleted file mode 100644 index 8c71022..0000000 --- a/ports/websocket/deno.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@deltic/port-websocket", - "version": "0.0.0", - "exports": "./src/websocket.ts", - "//": "This package is deliberately NOT a member of the root workspace (like tools/smoke-c0): it imports the runtime, wasi-shims and ct-runner by relative path. The one alias below is what wasi-shims itself uses internally; it resolves to the same file URL as the relative imports here, so there is exactly one module instance and `instanceof ComponentException` holds across the boundary.", - "imports": { - "@deltic/runtime/embedder": "../../runtime/src/embedder/mod.ts" - }, - "tasks": { - "check": "deno check src tests conformance", - "test": "deno test --allow-net=127.0.0.1,localhost tests", - "conformance": "DENO_CERT=/home/lmartin/p/polymorph/polymorph-websocket/conformance/server/tls/ca.pem deno run -A conformance/run.ts" - } -} diff --git a/ports/websocket/deno.lock b/ports/websocket/deno.lock deleted file mode 100644 index 1530515..0000000 --- a/ports/websocket/deno.lock +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "5", - "specifiers": { - "jsr:@std/assert@1": "1.0.19", - "jsr:@std/internal@^1.0.12": "1.0.14" - }, - "jsr": { - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal" - ] - }, - "@std/internal@1.0.14": { - "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" - } - }, - "workspace": { - "links": { - "jsr:@deltic/ct-runner@0.1.0": {}, - "jsr:@deltic/protocol@0.2.0": {}, - "jsr:@deltic/runtime@0.1.0": {}, - "jsr:@deltic/translator@0.1.0": {}, - "jsr:@deltic/wasi-shims@0.1.0": {} - } - } -} diff --git a/ports/websocket/src/websocket.ts b/ports/websocket/src/websocket.ts deleted file mode 100644 index dadff56..0000000 --- a/ports/websocket/src/websocket.ts +++ /dev/null @@ -1,943 +0,0 @@ -// Host module for `polymorph:websocket/connections@0.1.0`, ported to the -// deltic embedder conventions (contracts/embedder-api.md). -// -// PORT PROVENANCE. This is a faithful translation of the consumer's -// browser-first reference host, -// `polymorph-websocket/js/jco/websocket.js` (cited below as -// `websocket.js:LINE`), which the consumer's own conformance suite -// asserts the behavior of. The LOGIC — timeout defaults, buffer bounds, -// close-code validation, subprotocol validation, overflow-close -// behavior, the receive-via-stream single-use rule — is preserved -// line-for-line; only the *conventions* are translated: -// -// jco | this port -// -------------------------------------+------------------------------------ -// `throw { tag, val }` (bare payload) | `throw new ComponentException({ kind, value })` -// jco `Stream` (`read({count})`) | `Stream` / `ReadableStream` -// module-namespace `--map` wiring | `websocketImports()` record fragment -// module-level setters | `configure()` + compatible setters -// -// The WIT contract is `polymorph-websocket/wit/websocket.wit`; every -// doc comment below that quotes a contract quotes that file. -// -// Runtime: Deno's native `WebSocket` (the W3C API). The module reads no -// ambient configuration — no environment variables, no globals — exactly -// as the reference does (websocket.js:57-63). - -import { - Stream, - type StreamSource, - ComponentException, -} from "../../../runtime/src/embedder/mod.ts"; - -// ----- WIT value types (contracts/embedder-api.md §"Value mapping") --------- - -/** `types.error` — a variant; `value` is absent for payloadless cases. */ -export type WebsocketError = - | { kind: "invalid-url"; value: string } - | { kind: "connect-failed"; value: string } - | { kind: "closed" } - | { kind: "receiving-via-stream" } - | { kind: "receive-buffer-overflow" } - | { kind: "invalid-argument"; value: string } - | { kind: "other"; value: string }; - -/** - * `types.message` — `variant { binary(list), %string(string) }`. - * - * The WIT case name is `%string`; `%` is WIT's identifier escape, not part - * of the name, so the conventions' "kebab-case verbatim" tag is `"string"`. - */ -export type Message = - | { kind: "binary"; value: Uint8Array } - | { kind: "string"; value: string }; - -/** `types.message-kind` — an enum, so a string-literal union. */ -export type MessageKind = "binary" | "string"; - -/** `types.stream-message`. `data` is a `stream`. */ -export interface StreamMessage { - kind: MessageKind; - length: number; - data: StreamSource; -} - -/** The lifted shape of `stream-message` when the guest hands one over. */ -interface LiftedStreamMessage { - kind: MessageKind; - length: number; - data: Stream; -} - -/** `types.send-via-stream-error`. `sent` is a `u64`, hence `bigint`. */ -export interface SendViaStreamError { - error: WebsocketError; - sent: bigint; -} - -/** `types.close-info`. */ -export interface CloseInfo { - code: number; - reason: string; -} - -/** `types.websocket-state` — an enum. */ -export type WebsocketState = "open" | "closing" | "closed"; - -/** Throw a WIT `error` the branded way (contracts/embedder-api.md §"Error model"). */ -function componentException(payload: WebsocketError): ComponentException { - return new ComponentException( - payload, - payload.kind + ("value" in payload ? `: ${payload.value}` : ""), - ); -} - -// ----- configuration ------------------------------------------------------- -// Defaults are the reference module's, byte for byte (websocket.js:25-50). - -/** How long `connect` waits for the handshake before failing `connect-failed` - * (the WIT leaves the bound implementation-defined). websocket.js:27. */ -const DEFAULT_CONNECT_TIMEOUT_MS = 30_000; - -/** How long a locally initiated close may wait for the peer's acknowledgement - * before the resource settles as closed anyway. websocket.js:33. */ -const DEFAULT_CLOSE_TIMEOUT_MS = 10_000; - -/** The default bound on buffered inbound payload bytes awaiting `receive`. - * websocket.js:41. */ -const DEFAULT_MAX_INBOUND_BUFFERED = 8 * 1024 * 1024; - -/** Keep the send buffer bounded; pause the producer while it drains. - * `WebSocket` has no `bufferedamountlow` event, so draining is polled. - * websocket.js:46-47. */ -const MAX_BUFFERED_AMOUNT = 8 * 1024 * 1024; -const DRAIN_POLL_MS = 4; - -/** Batch size for reads from a guest byte stream. websocket.js:50. */ -const READ_BATCH = 65536; - -/** The configured knobs; connections capture them at `connect`. websocket.js:53-55. */ -let maxInboundBuffered = DEFAULT_MAX_INBOUND_BUFFERED; -let connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; -let closeTimeoutMs = DEFAULT_CLOSE_TIMEOUT_MS; - -/** The options surface the conventions prefer; every field is optional. */ -export interface WebsocketOptions { - /** Per-connection inbound buffer bound, in payload bytes. */ - maxInboundBufferBytes?: number; - /** The `connect` handshake bound, in milliseconds. */ - connectTimeoutMs?: number; - /** The closing-handshake bound, in milliseconds. */ - closeTimeoutMs?: number; -} - -/** - * Apply configuration. Like the reference module, this reads no ambient - * configuration: a host that offers these as knobs reads and validates the - * values itself. Throws (a plain `Error` — this is a host-side API, not a - * guest-visible boundary) on anything but positive finite numbers. - */ -export function configure(options: WebsocketOptions): void { - if (options.maxInboundBufferBytes !== undefined) { - setMaxInboundBufferBytes(options.maxInboundBufferBytes); - } - if (options.connectTimeoutMs !== undefined) { - setConnectTimeoutMs(options.connectTimeoutMs); - } - if (options.closeTimeoutMs !== undefined) { - setCloseTimeoutMs(options.closeTimeoutMs); - } -} - -/** The current configuration (a copy). */ -export function currentConfig(): Required { - return { - maxInboundBufferBytes: maxInboundBuffered, - connectTimeoutMs, - closeTimeoutMs, - }; -} - -/** Reset every knob to the reference module's defaults. */ -export function resetConfig(): void { - maxInboundBuffered = DEFAULT_MAX_INBOUND_BUFFERED; - connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; - closeTimeoutMs = DEFAULT_CLOSE_TIMEOUT_MS; -} - -/** Set the per-connection inbound buffer bound, in payload bytes. - * Signature-compatible with websocket.js:64. */ -export function setMaxInboundBufferBytes(bytes: number): void { - if (!(Number.isFinite(bytes) && bytes > 0)) { - throw new Error( - `invalid inbound buffer bound ${bytes}: expected a positive byte count`, - ); - } - maxInboundBuffered = bytes; -} - -/** Set the `connect` handshake bound, in milliseconds. websocket.js:72. */ -export function setConnectTimeoutMs(ms: number): void { - if (!(Number.isFinite(ms) && ms > 0)) { - throw new Error(`invalid connect timeout ${ms}: expected positive milliseconds`); - } - connectTimeoutMs = ms; -} - -/** Set the closing-handshake bound, in milliseconds. websocket.js:80. */ -export function setCloseTimeoutMs(ms: number): void { - if (!(Number.isFinite(ms) && ms > 0)) { - throw new Error(`invalid close timeout ${ms}: expected positive milliseconds`); - } - closeTimeoutMs = ms; -} - -// ----- validation (websocket.js:87-176, verbatim logic) -------------------- - -const utf8 = new TextEncoder(); - -/** The UTF-8 byte length of a string (the WIT bounds count bytes). */ -function utf8ByteLength(text: string): number { - return utf8.encode(text).byteLength; -} - -/** - * Whether `token` is a valid RFC 6455 subprotocol token (an RFC 2616 - * `token`: 1+ US-ASCII characters, no separators or control characters). - * websocket.js:97. - */ -function isValidProtocolToken(token: string): boolean { - if (!token.length) return false; - for (let i = 0; i < token.length; i += 1) { - const c = token.charCodeAt(i); - if (c <= 0x20 || c >= 0x7f) return false; - if ('"(),/:;<=>?@[\\]{}'.includes(token[i])) return false; - } - return true; -} - -/** Validate a connect URL per the WIT contract; throws `invalid-url`. */ -function validateUrl(url: string): void { - if (url.includes("#")) { - throw componentException({ kind: "invalid-url", value: "URL must not have a fragment" }); - } - let parsed: URL; - try { - parsed = new URL(url); - } catch (err) { - throw componentException({ - kind: "invalid-url", - value: `URL does not parse: ${(err as Error)?.message ?? err}`, - }); - } - if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { - throw componentException({ - kind: "invalid-url", - value: `URL scheme must be ws or wss, not ${JSON.stringify(parsed.protocol)}`, - }); - } - if (!parsed.hostname) { - throw componentException({ kind: "invalid-url", value: "URL must have a host" }); - } - // The WHATWG WebSocket constructor rejects credentials in the URL; the - // eager taxonomy matches that floor uniformly. websocket.js:127-131. - if (parsed.username || parsed.password) { - throw componentException({ kind: "invalid-url", value: "URL must not have userinfo" }); - } -} - -/** Validate a subprotocol offer per the WIT contract; throws `invalid-argument`. */ -function validateProtocols(protocols: string[]): void { - for (let i = 0; i < protocols.length; i += 1) { - const protocol = protocols[i]; - if (!isValidProtocolToken(protocol)) { - throw componentException({ - kind: "invalid-argument", - value: `subprotocol ${JSON.stringify(protocol)} is not a valid token`, - }); - } - if (protocols.indexOf(protocol) !== i) { - throw componentException({ - kind: "invalid-argument", - value: `subprotocol ${JSON.stringify(protocol)} is offered twice`, - }); - } - } -} - -/** - * Validate close arguments per the WIT contract: `code` 1000 or 3000-4999, - * `reason` at most 123 UTF-8 bytes and only alongside a code. Throws - * `invalid-argument`. websocket.js:158. - */ -function validateCloseArgs(code: number | undefined, reason: string): void { - if (code !== undefined && code !== null) { - if (code !== 1000 && !(code >= 3000 && code <= 4999)) { - throw componentException({ - kind: "invalid-argument", - value: `close code must be 1000 or in 3000-4999, not ${code}`, - }); - } - } else if (reason.length) { - throw componentException({ - kind: "invalid-argument", - value: "a close reason requires a close code", - }); - } - const bytes = utf8ByteLength(reason); - if (bytes > 123) { - throw componentException({ - kind: "invalid-argument", - value: `close reason must be at most 123 bytes, got ${bytes}`, - }); - } -} - -// ----- the resource -------------------------------------------------------- - -/** - * Close codes that are *synthesized by the platform* and never carried by a - * close frame, so they mean "the peer sent no close frame" — see - * `Websocket.#settleClosed` for the full rationale, including the Deno `0`. - */ -const SYNTHESIZED_CLOSE_CODES = new Set([0, 1006, 1015]); - -interface IncomingQueue { next(): Promise; - rejectWaiters(error: WebsocketError): void; - end(): void; - discard(): void; -} - -/** - * The `websocket` resource: an open WebSocket client connection over the - * standard `WebSocket` API. A host-implemented resource class per - * contracts/embedder-api.md §"Resources": methods camelCase, the WIT - * static as a static member, `[Symbol.dispose]` as the dtor. - */ -export class Websocket { - #ws: WebSocket; - #incoming: IncomingQueue; - /** Set by a local `close()` (or dispose): the close is observed locally - * at once and the unread backlog is discarded. */ - #localClosed = false; - /** Set once `receive-via-stream` has claimed the inbound messages. */ - #streamClaimed = false; - /** Whether `wait-closed` has settled. */ - #closeSettled = false; - #closeInfo: CloseInfo | undefined = undefined; - #closeWaiters: ((info: CloseInfo | undefined) => void)[] = []; - #closeDeadline: ReturnType | null = null; - - /** - * `connect: static async func(url, protocols) -> result`. - * Resolves with a `Websocket` once the handshake completes; throws - * `ComponentException` on failure. websocket.js:203. - */ - static async connect(url: string, protocols: string[]): Promise { - validateUrl(url); - validateProtocols(protocols); - - let ws: WebSocket; - try { - ws = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url); - } catch (err) { - // Eager validation covered the SyntaxError cases; anything left is a - // platform policy refusing the connection. - throw componentException({ - kind: "connect-failed", - value: String((err as Error)?.message ?? err), - }); - } - ws.binaryType = "arraybuffer"; - - await new Promise((resolve, reject) => { - let timer: ReturnType; - const settle = (fn: (v?: unknown) => void, value?: unknown) => { - clearTimeout(timer); - ws.removeEventListener("open", onOpen); - ws.removeEventListener("close", onClose); - ws.removeEventListener("error", onError); - fn(value); - }; - const onOpen = () => settle(resolve as (v?: unknown) => void); - // Platforms deliberately hide connect-failure diagnostics; the close - // code is all there is, and it is usually 1006. websocket.js:227-233. - const onClose = (event: Event) => { - const ce = event as CloseEvent; - settle( - reject, - componentException({ - kind: "connect-failed", - value: ce.reason || `connection failed (code ${ce.code})`, - }), - ); - }; - const onError = () => { - // An `error` event is always followed by `close`; wait for it so - // the reason (if any) rides along. - }; - ws.addEventListener("open", onOpen, { once: true }); - ws.addEventListener("close", onClose, { once: true }); - ws.addEventListener("error", onError, { once: true }); - timer = setTimeout(() => { - settle( - reject, - componentException({ - kind: "connect-failed", - value: `handshake timed out after ${connectTimeoutMs}ms`, - }), - ); - try { - ws.close(); - } catch { - // Nothing to reclaim. - } - }, connectTimeoutMs); - }); - - // The platform enforces the offer contract natively; these guards keep - // the taxonomy identical on runtimes that are lax about it. - // websocket.js:256-279. - if (protocols.length && !protocols.includes(ws.protocol)) { - try { - ws.close(); - } catch { /* already closing */ } - throw componentException({ - kind: "connect-failed", - value: ws.protocol - ? `server selected subprotocol ${JSON.stringify(ws.protocol)} which was not offered` - : "server selected no subprotocol although one was offered", - }); - } - if (!protocols.length && ws.protocol) { - try { - ws.close(); - } catch { /* already closing */ } - throw componentException({ - kind: "connect-failed", - value: `server selected subprotocol ${ - JSON.stringify(ws.protocol) - } although none was offered`, - }); - } - - return new Websocket(ws); - } - - /** @param ws an OPEN `WebSocket` */ - constructor(ws: WebSocket) { - this.#ws = ws; - this.#incoming = incomingQueue(ws, () => this.#transportClosing()); - ws.addEventListener("close", (event) => this.#settleClosed(event as CloseEvent), { - once: true, - }); - // `error` without `close` does not happen per spec; the close listener - // is the single settle point. - ws.addEventListener("error", () => {}, { once: true }); - } - - /** `protocol: func() -> string` — the negotiated subprotocol, or "". */ - protocol(): string { - return this.#ws.protocol; - } - - /** - * `send: async func(message) -> result<_, error>`. Resolves once the - * message is handed to the transport; throws `closed` once a close was - * initiated (locally or by the peer) — messages are never silently - * discarded. websocket.js:305. - */ - async send(message: Message): Promise { - for (;;) { - if (this.#localClosed || this.#ws.readyState !== WebSocket.OPEN) { - throw componentException({ kind: "closed" }); - } - if (this.#ws.bufferedAmount <= MAX_BUFFERED_AMOUNT) break; - // No `bufferedamountlow` on WebSocket: poll the drain. - await new Promise((resolve) => setTimeout(resolve, DRAIN_POLL_MS)); - } - try { - this.#ws.send(message.value as string | Uint8Array); - } catch (err) { - throw componentException({ kind: "other", value: String((err as Error)?.message ?? err) }); - } - } - - /** - * `receive: async func() -> result`. Throws the WIT - * `error` once the connection closes. websocket.js:325. - */ - receive(): Promise { - if (this.#localClosed) return Promise.reject(componentException({ kind: "closed" })); - if (this.#streamClaimed) { - return Promise.reject(componentException({ kind: "receiving-via-stream" })); - } - return this.#incoming.next(); - } - - /** - * `send-via-stream: async func(stream) -> result<_, send-via-stream-error>`. - * Throws `ComponentException`. websocket.js:336. - */ - async sendViaStream(messages: Stream): Promise { - let sent = 0n; - try { - for await (const item of streamItems(messages)) { - // Buffering is bounded by the declared length; bytes past it are - // counted, not stored, so a mis-declared length cannot grow host - // memory without bound. websocket.js:341-349. - const { bytes, excess } = await collectByteStream(item.data, item.length); - if (excess > 0 || bytes.length !== item.length) { - throw componentException({ - kind: "other", - value: `stream-message payload was ${ - bytes.length + excess - } bytes but length declared ${item.length}`, - }); - } - let message: Message; - if (item.kind === "string") { - // The payload must be valid UTF-8, per the streaming contract; a - // lossy decode would silently send mangled text. - let text: string; - try { - text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - throw componentException({ - kind: "other", - value: "string stream-message payload is not valid UTF-8", - }); - } - message = { kind: "string", value: text }; - } else { - message = { kind: "binary", value: bytes }; - } - await this.send(message); - sent += 1n; - } - } catch (error) { - // A WIT error variant passes through; anything else is a host-side - // failure and must not masquerade as a normal close. websocket.js:370-378. - const payload: WebsocketError = error instanceof ComponentException - ? error.payload as WebsocketError - : { kind: "other", value: String(error) }; - throw new ComponentException( - { error: payload, sent }, - `send-via-stream failed after ${sent} message(s)`, - ); - } - } - - /** - * `receive-via-stream: func() -> result, error>`. - * Once-only: a second call (or any later `receive`) throws - * `receiving-via-stream`, and any pending `receive` is rejected with it. - * The stream ends when the connection closes. websocket.js:388. - * - * Returns a `ReadableStream`, one of the natural JS producers the - * conventions accept where a `stream` is expected - * (contracts/embedder-api.md §"Streams and futures"). - */ - receiveViaStream(): ReadableStream { - if (this.#localClosed) throw componentException({ kind: "closed" }); - if (this.#streamClaimed) throw componentException({ kind: "receiving-via-stream" }); - this.#streamClaimed = true; - const incoming = this.#incoming; - incoming.rejectWaiters({ kind: "receiving-via-stream" }); - return new ReadableStream({ - async pull(controller) { - let message: Message; - try { - message = await incoming.next(); - } catch { - // The connection closed (or its inbound buffer overflowed): the - // stream simply ends, per the WIT contract. - controller.close(); - return; - } - const bytes = message.kind === "string" - ? new TextEncoder().encode(message.value) - : message.value; - controller.enqueue({ - kind: message.kind, - length: bytes.length, - data: bytesToStream(bytes), - }); - }, - }); - } - - /** `state: func() -> websocket-state`. `closed` is terminal and latched. */ - state(): WebsocketState { - return this.#currentState(); - } - - /** - * `wait-closed: async func() -> option`. Latched: every call - * resolves with the same value. `option` in the outermost position maps - * to `T | undefined` (contracts/embedder-api.md §"Value mapping"). - */ - waitClosed(): Promise { - if (this.#closeSettled) return Promise.resolve(this.#closeInfo); - return new Promise((resolve) => this.#closeWaiters.push(resolve)); - } - - /** - * `close: func(code: option, reason: string) -> result<_, error>` — - * deliberately synchronous. Validate eagerly, then initiate the closing - * handshake and return. Idempotent after the first accepted call. - * websocket.js:441. - */ - close(code: number | undefined, reason: string): void { - validateCloseArgs(code, reason); - if (this.#localClosed) return; - this.#localClosed = true; - this.#incoming.discard(); - // The resource settles as closed within the close bound even when the - // peer never acknowledges. - this.#closeDeadline = setTimeout(() => this.#settleClosed(null), closeTimeoutMs); - try { - if (code === undefined || code === null) { - this.#ws.close(); - } else if (reason.length) { - this.#ws.close(code, reason); - } else { - this.#ws.close(code); - } - } catch { - // Validation covered the argument errors; per the WIT contract the - // close result reflects arguments only, and the deadline above - // already bounds the teardown, so a platform throw past this point - // must not surface. - } - } - - /** - * The dtor the runtime invokes when the guest drops its last own handle - * (contracts/embedder-api.md §"Resources"): dropping without `close` - * implies `close(none, "")`, per the WIT contract. - */ - [Symbol.dispose](): void { - try { - this.close(undefined, ""); - } catch { - // Already closed. - } - } - - /** - * A close was initiated below the resource (an inbound-buffer overflow): - * bound the teardown. Unlike a guest-initiated `close`, the receivable - * backlog is kept — overflow readers drain it before observing the - * overflow error. websocket.js:483. - */ - #transportClosing(): void { - if (!this.#closeSettled && this.#closeDeadline === null) { - this.#closeDeadline = setTimeout(() => this.#settleClosed(null), closeTimeoutMs); - } - } - - #currentState(): WebsocketState { - if (this.#closeSettled) return "closed"; - if (this.#localClosed) return "closing"; - switch (this.#ws.readyState) { - case WebSocket.CLOSING: - return "closing"; - case WebSocket.CLOSED: - return "closed"; - default: - return "open"; - } - } - - /** - * Settle the close outcome. `event` is the `CloseEvent`, or `null` when - * the close bound expired first. Codes 1006 (abnormal) and 1015 (TLS - * failure) are synthesized by the platform, never carried by a frame, so - * they map to "no close-info", per the WIT close contract - * (wit/websocket.wit:106-118: "A `close-info` exists only when the peer - * actually sent a close frame ... and implementations never invent one"). - * websocket.js:508. - * - * DENO DELTA (the one behavioral divergence from the reference host). - * On an abnormal closure — the peer drops TCP with no close frame — - * browsers and Node deliver `CloseEvent.code === 1006`, but **Deno - * delivers `0`** (verified empirically against the suite's own - * `/abrupt-close` endpoint: `{code: 0, reason: "", wasClean: false}`, - * versus `{code: 4001, wasClean: true}` for a real close frame on the - * same runtime). Code 0 is not a wire value at all — no frame carried - * it — so it belongs in exactly the same bucket as 1006. Without this, - * `websocket/close/abnormal` and `websocket/tls/abrupt-close` fail with - * "abnormal closure produced close-info code=0". - * - * 1005 is deliberately NOT in the set: it is the legitimate observation - * of a close frame that carried no code (wit/websocket.wit:113-115), and - * the suite asserts `close-info{code: 1005}` for it - * (`close/local-default`, `close/remote-no-code`). - */ - #settleClosed(event: CloseEvent | null): void { - if (this.#closeSettled) return; - this.#closeSettled = true; - if (this.#closeDeadline !== null) { - clearTimeout(this.#closeDeadline); - this.#closeDeadline = null; - } - // A settle without a close event (the deadline path) must still end - // the inbound queue, or a pending receive would hang past the close. - this.#incoming.end(); - if (event && !SYNTHESIZED_CLOSE_CODES.has(event.code)) { - this.#closeInfo = { code: event.code, reason: event.reason ?? "" }; - } else { - this.#closeInfo = undefined; - } - const waiters = this.#closeWaiters; - this.#closeWaiters = []; - for (const resolve of waiters) resolve(this.#closeInfo); - } -} - -// ----- helpers ------------------------------------------------------------- - -/** - * Build a per-message inbound queue over `ws` (websocket.js:543). - * - * Buffering is bounded (in payload bytes): a message that would exceed the - * bound closes the connection — reported through `onOverflowClose` so the - * owning resource can bound the teardown — and discards that and any later - * messages; the pre-overflow backlog stays deliverable, after which - * `next()` rejects with `receive-buffer-overflow`. - */ -function incomingQueue(ws: WebSocket, onOverflowClose: () => void): IncomingQueue { - // Captured at construction: connections capture the knobs at `connect`. - const limit = maxInboundBuffered; - const messages: { message: Message; size: number }[] = []; - const waiters: { - resolve: (m: Message) => void; - reject: (e: unknown) => void; - }[] = []; - let buffered = 0; - let overflowed = false; - let closed = false; - - const push = (message: Message, size: number) => { - const waiter = waiters.shift(); - if (waiter) { - waiter.resolve(message); - } else { - buffered += size; - messages.push({ message, size }); - } - }; - - ws.addEventListener("message", (event) => { - const data = (event as MessageEvent).data; - if (overflowed) return; - // Account string payloads in UTF-8 bytes (the WIT bound counts payload - // bytes; `.length` would count UTF-16 code units). - const size = typeof data === "string" - ? utf8ByteLength(data) - : (data as ArrayBuffer).byteLength; - // The bound applies to queued bytes: a pending waiter implies an empty - // queue (`buffered` is 0), so this reduces to `size > limit` then — a - // single message larger than the whole bound overflows even when a - // receiver is waiting for it, matching the wasmtime host. - // websocket.js:566-582. - if (buffered + size > limit) { - overflowed = true; - try { - ws.close(); - } catch { /* already closing */ } - onOverflowClose(); - return; - } - const message: Message = typeof data === "string" - ? { kind: "string", value: data } - : { kind: "binary", value: new Uint8Array(data as ArrayBuffer) }; - push(message, size); - }); - - const endError = (): WebsocketError => - overflowed ? { kind: "receive-buffer-overflow" } : { kind: "closed" }; - const end = () => { - if (closed) return; - closed = true; - while (waiters.length) { - waiters.shift()!.reject(componentException(endError())); - } - }; - ws.addEventListener("close", end); - ws.addEventListener("error", end); - - return { - next(): Promise { - if (messages.length) { - const { message, size } = messages.shift()!; - buffered -= size; - return Promise.resolve(message); - } - if (overflowed) { - return Promise.reject(componentException({ kind: "receive-buffer-overflow" })); - } - if (closed) return Promise.reject(componentException({ kind: "closed" })); - return new Promise((resolve, reject) => waiters.push({ resolve, reject })); - }, - rejectWaiters(error: WebsocketError) { - while (waiters.length) { - waiters.shift()!.reject(componentException(error)); - } - }, - end, - /** Discard the unread backlog and fail pending and future reads - * `closed` (a local `close`, per the WIT contract). */ - discard() { - messages.length = 0; - buffered = 0; - closed = true; - while (waiters.length) { - waiters.shift()!.reject(componentException({ kind: "closed" })); - } - }, - }; -} - -/** - * Iterate a guest-provided WIT stream. The conventions hand the host a - * `Stream` handle whose async iterator yields `Chunk` — an *array* - * of elements for a non-`u8` element type — so a batched read is flattened - * here. A web `ReadableStream` is also tolerated (websocket.js:644). - */ -async function* streamItems( - stream: Stream | ReadableStream | AsyncIterable, -): AsyncGenerator { - if (typeof ReadableStream !== "undefined" && stream instanceof ReadableStream) { - const reader = stream.getReader(); - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - yield value as LiftedStreamMessage; - } - } finally { - reader.releaseLock(); - } - return; - } - for await (const value of stream as AsyncIterable) { - // A batched read yields an array of elements. - if (Array.isArray(value)) { - yield* value as LiftedStreamMessage[]; - } else { - yield value as LiftedStreamMessage; - } - } -} - -/** - * Coerce one chunk of a WIT byte stream (a number, an array of numbers, or - * a typed array, depending on how the runtime batched the read) to a - * `Uint8Array`. websocket.js:673. - */ -function toByteChunk(value: unknown): Uint8Array { - if (typeof value === "number") return Uint8Array.of(value); - if (value instanceof Uint8Array) return value; - return Uint8Array.from(value as ArrayLike); -} - -/** A single-chunk byte `ReadableStream` over `bytes`. websocket.js:680. */ -function bytesToStream(bytes: Uint8Array): ReadableStream { - return new ReadableStream({ - start(controller) { - if (bytes.length) controller.enqueue(bytes); - controller.close(); - }, - }); -} - -/** - * Collect a WIT byte stream into one `Uint8Array`, storing at most `limit` - * bytes; bytes past the limit are consumed and counted in `excess`, never - * buffered. websocket.js:694. - */ -async function collectByteStream( - stream: Stream | ReadableStream | AsyncIterable, - limit: number, -): Promise<{ bytes: Uint8Array; excess: number }> { - const chunks: Uint8Array[] = []; - let total = 0; - let excess = 0; - const push = (value: unknown) => { - if (value === undefined || value === null) return; - let chunk = toByteChunk(value); - if (!chunk.length) return; - const room = limit - total; - if (chunk.length > room) { - excess += chunk.length - Math.max(room, 0); - if (room <= 0) return; - chunk = chunk.subarray(0, room); - } - chunks.push(chunk); - total += chunk.length; - }; - if (typeof ReadableStream !== "undefined" && stream instanceof ReadableStream) { - const reader = stream.getReader(); - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - push(value); - } - } finally { - reader.releaseLock(); - } - } else if (stream instanceof Stream) { - // The conventions' `Stream`: `read(max)` yields a `Uint8Array`, and - // an EMPTY chunk means end-of-stream (contracts/embedder-api.md - // §"Streams and futures"). Read in batches rather than per element. - for (;;) { - const chunk = await stream.read(READ_BATCH); - if ((chunk as Uint8Array).length === 0) break; - push(chunk); - } - } else { - for await (const value of stream as AsyncIterable) { - push(value); - } - } - const out = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return { bytes: out, excess }; -} - -// ----- wiring -------------------------------------------------------------- - -/** The exact WIT interface id the suite imports (`requiredImports` confirms it). */ -export const CONNECTIONS_INTERFACE = "polymorph:websocket/connections@0.1.0"; - -/** - * The interface-shaped aggregate export (contracts/embedder-api.md - * §"Module wiring and instantiation": "a module's named export, camelCase - * of the interface short-name, provides that interface" — so - * `export const connections = { Websocket }` survives the port unchanged). - */ -export const connections = { Websocket }; - -/** - * The imports-record fragment for `instantiate`: - * `{ "polymorph:websocket/connections@0.1.0": { Websocket } }`. - * - * Registered at the interface's **exact version**, not its `@0.1` track - * key: the package is at `0.1.0` and only one version exists, so an exact - * key is the narrowest correct registration (the resolver derives the - * track alternate automatically — contracts/embedder-api.md - * §"Version canonicalization", "Registration forms"). - */ -export function websocketImports( - options?: WebsocketOptions, -): Record { - if (options) configure(options); - return { [CONNECTIONS_INTERFACE]: connections }; -} diff --git a/ports/websocket/tests/echo_server.ts b/ports/websocket/tests/echo_server.ts deleted file mode 100644 index 95b5cbd..0000000 --- a/ports/websocket/tests/echo_server.ts +++ /dev/null @@ -1,103 +0,0 @@ -// A local WebSocket echo server for the port's unit tests (`Deno.serve` + -// `Deno.upgradeWebSocket`). Deliberately a *small* subset of the consumer's -// `conformance-echod` protocol — just what the unit tests stimulate; the -// real thing is spawned by conformance/run.ts. -// -// Paths (mirroring conformance/server/PROTOCOL.md where they overlap): -// /echo echo every message verbatim; echo the close frame -// /echo?protocol=NAME select NAME if offered -// /stall never answer the handshake -// /burst?count=N&size=S send N binary messages of S bytes, then idle - -export interface TestServer { - base: string; - close(): Promise; -} - -/** The payload `/burst` sends for message `index` (echod's rule). */ -export function burstPayload(index: number, size: number): Uint8Array { - const out = new Uint8Array(size); - for (let i = 0; i < size; i++) out[i] = (index + i) % 256; - return out; -} - -export async function startEchoServer(): Promise { - const sockets = new Set(); - let resolvePort: (p: number) => void; - const portReady = new Promise((r) => (resolvePort = r)); - - const ac = new AbortController(); - const server = Deno.serve( - { - hostname: "127.0.0.1", - port: 0, - signal: ac.signal, - onListen: ({ port }) => resolvePort(port), - onError: (e) => new Response(String(e), { status: 500 }), - }, - (req) => handle(req, sockets), - ); - - const port = await portReady; - return { - base: `ws://127.0.0.1:${port}`, - async close() { - for (const ws of sockets) { - try { - ws.close(); - } catch { /* already closing */ } - } - sockets.clear(); - ac.abort(); - try { - await server.finished; - } catch { /* aborted */ } - }, - }; -} - -function handle(req: Request, sockets: Set): Response | Promise { - const url = new URL(req.url); - if (url.pathname === "/stall") { - // Never answer: hold the request open until the client gives up. - return new Promise(() => {}); - } - if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") { - return new Response("not a websocket request", { status: 400 }); - } - - const offered = (req.headers.get("sec-websocket-protocol") ?? "") - .split(",") - .map((p) => p.trim()) - .filter((p) => p.length > 0); - const wanted = url.searchParams.get("protocol"); - // Selected only when actually offered: `Deno.upgradeWebSocket` refuses to - // select an unoffered subprotocol, so the "server forced an unoffered - // protocol" row is left to the real conformance server. - const selected = wanted !== null && offered.includes(wanted) ? wanted : undefined; - const { socket, response } = Deno.upgradeWebSocket(req, { protocol: selected }); - sockets.add(socket); - socket.binaryType = "arraybuffer"; - socket.addEventListener("close", () => sockets.delete(socket)); - - if (url.pathname === "/echo") { - socket.addEventListener("message", (e) => { - const data = (e as MessageEvent).data; - try { - socket.send(typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer)); - } catch { /* closing */ } - }); - } else if (url.pathname === "/burst") { - const count = Number(url.searchParams.get("count") ?? "1"); - const size = Number(url.searchParams.get("size") ?? "16"); - socket.addEventListener("open", () => { - for (let i = 0; i < count; i++) { - try { - socket.send(burstPayload(i, size)); - } catch { /* closing */ } - } - }); - } - - return response; -} diff --git a/ports/websocket/tests/websocket_test.ts b/ports/websocket/tests/websocket_test.ts deleted file mode 100644 index 71ba29f..0000000 --- a/ports/websocket/tests/websocket_test.ts +++ /dev/null @@ -1,286 +0,0 @@ -// Unit tests for the `polymorph:websocket/connections` port, against a -// local Deno echo server (tests/echo_server.ts). -// -// Scope: the LOGIC ported from the consumer's reference host — connect, -// send/receive of both message kinds, subprotocol negotiation and the -// offer-enforcement rule, close-argument validation, receive-via-stream -// (happy path and the single-use rule), the overflow close under a shrunk -// buffer bound, and the connect bound. The full behavioral surface is the -// consumer's own conformance suite, executed by conformance/run.ts. - -import { assert, assertEquals, assertRejects, assertThrows } from "jsr:@std/assert@^1.0.0"; -import { ComponentException } from "../../../runtime/src/embedder/mod.ts"; -import { - currentConfig, - resetConfig, - setConnectTimeoutMs, - setMaxInboundBufferBytes, - type StreamMessage, - type WebsocketError, - Websocket, -} from "../src/websocket.ts"; -import { burstPayload, startEchoServer, type TestServer } from "./echo_server.ts"; - -/** Assert `fn` throws a branded `ComponentException` whose payload kind is `kind`. */ -function assertComponentExceptionKind(fn: () => unknown, kind: WebsocketError["kind"]): WebsocketError { - const e = assertThrows(fn, ComponentException) as ComponentException; - assertEquals(e.payload.kind, kind); - return e.payload; -} - -async function assertRejectsComponentExceptionKind( - fn: () => Promise, - kind: WebsocketError["kind"], -): Promise { - const e = await assertRejects(fn, ComponentException) as ComponentException; - assertEquals(e.payload.kind, kind); - return e.payload; -} - -async function withServer(fn: (s: TestServer) => Promise): Promise { - resetConfig(); - const server = await startEchoServer(); - try { - await fn(server); - } finally { - await server.close(); - resetConfig(); - } -} - -Deno.test("connect + echo: text and binary round-trip, kinds preserved", async () => { - await withServer(async (s) => { - const ws = await Websocket.connect(`${s.base}/echo`, []); - assertEquals(ws.protocol(), ""); - assertEquals(ws.state(), "open"); - - await ws.send({ kind: "string", value: "héllo — 你好 🦀" }); - const text = await ws.receive(); - assertEquals(text, { kind: "string", value: "héllo — 你好 🦀" }); - - const payload = new Uint8Array([0, 1, 2, 253, 254, 255]); - await ws.send({ kind: "binary", value: payload }); - const bin = await ws.receive(); - assertEquals(bin.kind, "binary"); - assertEquals(bin.value as Uint8Array, payload); - - ws.close(1000, "bye"); - const info = await ws.waitClosed(); - assertEquals(ws.state(), "closed"); - // The test server does not echo the close frame's code/reason, so only - // the *shape* is asserted here; the consumer's echod does, and its - // `close/local` case asserts the round-trip. - assert(info === undefined || typeof info.code === "number"); - }); -}); - -Deno.test("subprotocol: negotiated when offered and selected", async () => { - await withServer(async (s) => { - const ws = await Websocket.connect(`${s.base}/echo?protocol=beta`, ["alpha", "beta"]); - assertEquals(ws.protocol(), "beta"); - ws.close(1000, ""); - await ws.waitClosed(); - }); -}); - -Deno.test("subprotocol: offered but none selected fails connect-failed", async () => { - await withServer(async (s) => { - // The WIT binds the server: "the connection fails if the server ... - // selects none at all" (wit/websocket.wit:181-185). - await assertRejectsComponentExceptionKind( - () => Websocket.connect(`${s.base}/echo`, ["alpha"]), - "connect-failed", - ); - }); -}); - -Deno.test("subprotocol: a malformed offer fails invalid-argument, eagerly", async () => { - await withServer(async (s) => { - for (const protocols of [["dup", "dup"], ["has space"], [""], ["bad,comma"]]) { - await assertRejectsComponentExceptionKind( - () => Websocket.connect(`${s.base}/echo`, protocols), - "invalid-argument", - ); - } - }); -}); - -Deno.test("connect: invalid URLs fail invalid-url, eagerly", async () => { - await withServer(async (s) => { - const host = s.base.slice("ws://".length); - for ( - const url of [ - `http://${host}/echo`, - `${s.base}/echo#fragment`, - `ws://user:secret@${host}/echo`, - "not a url", - "/echo", - ] - ) { - await assertRejectsComponentExceptionKind(() => Websocket.connect(url, []), "invalid-url"); - } - }); -}); - -Deno.test("close: argument validation is eager and leaves the connection usable", async () => { - await withServer(async (s) => { - const ws = await Websocket.connect(`${s.base}/echo`, []); - for (const code of [0, 999, 1001, 1005, 1006, 1015, 2999, 5000, 65535]) { - assertComponentExceptionKind(() => ws.close(code, ""), "invalid-argument"); - } - // A reason needs a code; 124 bytes is one too many; 123 is exact. - assertComponentExceptionKind(() => ws.close(undefined, "reason"), "invalid-argument"); - assertComponentExceptionKind(() => ws.close(1000, "r".repeat(124)), "invalid-argument"); - // The bound counts UTF-8 bytes, not code units: 42 three-byte chars - // overflow, 41 fit exactly. - assertComponentExceptionKind(() => ws.close(4000, "€".repeat(42)), "invalid-argument"); - - // A rejected close left the connection usable. - await ws.send({ kind: "binary", value: new Uint8Array([7, 7, 7]) }); - const echoed = await ws.receive(); - assertEquals(echoed.value as Uint8Array, new Uint8Array([7, 7, 7])); - - assertEquals(ws.state(), "open"); - ws.close(4999, "€".repeat(41)); - assertEquals(ws.state() === "open", false); - await ws.waitClosed(); - }); -}); - -Deno.test("close: local close discards the backlog and latches", async () => { - await withServer(async (s) => { - const ws = await Websocket.connect(`${s.base}/echo`, []); - await ws.send({ kind: "binary", value: new Uint8Array([1, 2, 3]) }); - ws.close(1000, ""); - // Idempotent: a second close is a no-op, not an error. - ws.close(4000, "second"); - await assertRejectsComponentExceptionKind(() => ws.receive(), "closed"); - await assertRejectsComponentExceptionKind( - () => ws.send({ kind: "binary", value: new Uint8Array([1]) }), - "closed", - ); - await ws.waitClosed(); - assertEquals(ws.state(), "closed"); - }); -}); - -Deno.test("receive-via-stream: happy path delivers one stream-message per message", async () => { - await withServer(async (s) => { - const ws = await Websocket.connect(`${s.base}/echo`, []); - const sent = [ - { kind: "binary", value: new Uint8Array([9, 8, 7, 6]) } as const, - { kind: "string", value: "streamed téxt ✓" } as const, - ]; - for (const m of sent) await ws.send(m); - - const stream = ws.receiveViaStream(); - const reader = stream.getReader(); - const got: { kind: string; bytes: Uint8Array }[] = []; - while (got.length < 2) { - const { value, done } = await reader.read(); - if (done) break; - const m = value as StreamMessage; - const bytes = await drainBytes(m.data as ReadableStream); - assertEquals(bytes.length, m.length); - got.push({ kind: m.kind, bytes }); - } - assertEquals(got.length, 2); - assertEquals(got[0].kind, "binary"); - assertEquals(got[0].bytes, new Uint8Array([9, 8, 7, 6])); - assertEquals(got[1].kind, "string"); - assertEquals(new TextDecoder().decode(got[1].bytes), "streamed téxt ✓"); - - reader.cancel(); - ws.close(1000, ""); - await ws.waitClosed(); - }); -}); - -Deno.test("receive-via-stream: single-use; pending receive is rejected", async () => { - await withServer(async (s) => { - const ws = await Websocket.connect(`${s.base}/echo`, []); - // A receive pending when the stream claims the connection must resolve - // with `receiving-via-stream` (wit/websocket.wit:239-243). - const pending = ws.receive(); - const stream = ws.receiveViaStream(); - await assertRejectsComponentExceptionKind(() => pending, "receiving-via-stream"); - assertComponentExceptionKind(() => ws.receiveViaStream(), "receiving-via-stream"); - await assertRejectsComponentExceptionKind(() => ws.receive(), "receiving-via-stream"); - await stream.cancel(); - ws.close(1000, ""); - await ws.waitClosed(); - }); -}); - -Deno.test("flow control: overflow closes, backlog stays receivable, then overflow error", async () => { - await withServer(async (s) => { - // A shrunk bound so a modest burst overflows it deterministically. - setMaxInboundBufferBytes(8 * 1024); - assertEquals(currentConfig().maxInboundBufferBytes, 8 * 1024); - const floodCount = 64; - const ws = await Websocket.connect( - `${s.base}/burst?count=${floodCount}&size=1024`, - [], - ); - await ws.waitClosed(); - - let drained = 0; - for (;;) { - let message; - try { - message = await ws.receive(); - } catch (e) { - assertEquals((e as ComponentException).payload.kind, "receive-buffer-overflow"); - break; - } - assertEquals(message.value as Uint8Array, burstPayload(drained, 1024)); - drained += 1; - assert(drained <= floodCount, "received more messages than were sent"); - } - assert(drained > 0, "pre-overflow backlog was not receivable"); - assert(drained < floodCount, "the buffer bound did not engage"); - }); -}); - -Deno.test("flow control: a message larger than the whole bound overflows immediately", async () => { - await withServer(async (s) => { - setMaxInboundBufferBytes(4 * 1024); - const ws = await Websocket.connect(`${s.base}/burst?count=1&size=8192`, []); - // Nothing precedes it in the backlog: the very first receive observes - // the overflow (wit/websocket.wit:165-169). - await assertRejectsComponentExceptionKind(() => ws.receive(), "receive-buffer-overflow"); - }); -}); - -Deno.test("connect: the handshake bound fires as connect-failed", async () => { - await withServer(async (s) => { - setConnectTimeoutMs(250); - const started = performance.now(); - const payload = await assertRejectsComponentExceptionKind( - () => Websocket.connect(`${s.base}/stall`, []), - "connect-failed", - ); - const elapsed = performance.now() - started; - assert(elapsed < 5_000, `connect bound did not fire promptly (${elapsed}ms)`); - assert("value" in payload && typeof payload.value === "string"); - }); -}); - -async function drainBytes(stream: ReadableStream): Promise { - const chunks: Uint8Array[] = []; - let total = 0; - const reader = stream.getReader(); - for (;;) { - const { value, done } = await reader.read(); - if (done) break; - chunks.push(value); - total += value.length; - } - const out = new Uint8Array(total); - let at = 0; - for (const c of chunks) { - out.set(c, at); - at += c.length; - } - return out; -} diff --git a/tools/smoke-tls/run.ts b/tools/smoke-tls/run.ts index afd7c32..06a8def 100644 --- a/tools/smoke-tls/run.ts +++ b/tools/smoke-tls/run.ts @@ -7,8 +7,9 @@ // Mirrors tools/smoke-c0 leg 4 (translate-only + import-surface enumeration) // and then goes one step further than the issue asks: the tls conformance // suite artifacts are fully composed (no network, no sockets — recon -// 2026-08-09), so the same ct-runner + wasi-shims path that runs the -// websocket suite (ports/websocket/conformance/run.ts) can execute them +// 2026-08-09), so the same ct-runner + wasi-shims path that ran the +// websocket suite (the retired ports/websocket conformance runner; the +// consumer's own deltic legs carry it now) can execute them // directly. All consumer artifacts are referenced by absolute path and are // READ-ONLY; nothing here writes to the polymorph trees. //