diff --git a/AGENTS.md b/AGENTS.md index 4ff96ff..9e92c16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,10 @@ wit/ # the polymorph:webcrypto package, one file per layer: # family files (aes.wit, rsa.wit, …) hold the # minting interfaces plus any family-shared # parameterization interface, and grow as - # algorithms are added + # algorithms are added; + # extension-conditions.json is the registry of + # the package's named extension conditions (see + # wit/README.md, "Error contract") rust/ # the Rust library surface (directory = crate name # minus the `polymorph-webcrypto-` family root) core/ # polymorph-webcrypto-core: the shared RustCrypto core of @@ -196,15 +199,17 @@ existing compositions, but providers must update to serve them. Adding a **`types.error` case** is always semver-major: the variant sits in return position, so a new case flows toward consumers whose bindings cannot represent it — there is no compatible path for variant growth. The variant -is therefore designed never to need growth: the closed cases carry the -generic kinds' universal conditions, `other(string)` carries operational +is therefore designed never to need growth: the closed cases are frozen +(the conditions the package's contracts named when the variant was +designed), `other(string)` carries operational conditions (never semantic conditions callers must branch on), and -`extension(extension-error)` carries named algorithm- and feature-specific -conditions by (`origin`, `name`) pair — see `wit/README.md`, "Error -contract". Before proposing a closed case, check whether the fail-closed -design maps the condition onto an existing one (it usually does) and -whether the condition is interface-specific (then it is an extension -condition, not a case). +`extension(extension-error)` carries every named condition outside the +closed set — kind-level and algorithm-level alike — by (`origin`, `name`) +pair, recorded in `wit/extension-conditions.json` and gated against the +implementation spellings — see `wit/README.md`, "Error +contract". A new named condition is never a closed case: check whether the +fail-closed design maps it onto an existing case (it usually does); +otherwise it is an extension pair. The evolution rules describe the cost of a change, not a prohibition — and they bind only once the package has external consumers, which it does not @@ -417,7 +422,7 @@ Run the recipes that cover what you changed, and fix anything they report. | `just test` | any Rust host/guest code (includes the guest-under-Wasmtime integration test). | | `just demo::build-component` | the `crypto-demo` guest or its WIT. | | `just demo::test-composed` | the `polymorph-webcrypto-guest-provider` provider, the demo driver, or any WIT (composes guest + provider + driver with `wac plug` and runs under `wasmtime`). | -| `just componentize::typecheck` | the `webcrypto-componentize` library. Asserts its exported surface against the Web Cryptography API definitions TypeScript ships; no component build, nothing generated. | +| `just componentize::typecheck` | the `webcrypto-componentize` library, or `wit/extension-conditions.json`. Asserts its exported surface against the Web Cryptography API definitions TypeScript ships, and its extension-condition table against the wit/ registry; no component build, nothing generated. | | `just componentize::test` | the `webcrypto-componentize` library, the componentize-demo guest, the in-guest provider, or any WIT. Gates in CI. Componentizes the JS demo guest from your tree (with the downloaded, digest-verified componentize-js — see the WPT row for the pin mechanics), composes it with the in-guest provider and driver, and runs it under `wasmtime`. The behavioral gate on the shim's checks the WPT census cannot observe (the SHA-1 collision postures, the extension-error transport). | | `just wpt::test` | the `webcrypto-componentize` library, its `wpt/` harness or vendored files, the in-guest provider, or any WIT. Gates in CI. The runner is componentized from your tree in seconds; the componentize-js build it needs is downloaded and digest-verified (`js/componentize/wpt/component.sh`), never compiled here. Changing `js/componentize/componentize-js.rev` triggers the `componentize-js-toolchain` workflow; this check then fails until that publishes *and* `just componentize::update-toolchain-digest` records the new digests. Intentional changes to the test census also need `just wpt::update-expectations`. | | `just conformance-ct::all` | any host/guest behavior the tests assert — the WIT surface, an implementation, the conformance suites/vectors/translation policy, or driver-ct/targets.toml. Runs the wasmtime-rustcrypto, composed, jco-node, and deltic-deno targets always (Node 24+, Deno), the jco-browser and deltic-browser legs under CI or CONFORMANCE_BROWSER=1, and the jco-firefox leg under CONFORMANCE_FIREFOX=1 (in CI it is the dedicated conformance-firefox job — Firefox needs a runner to itself), aggregating against the committed lockfiles and target manifests and building the compat matrix (results/compat.json); the jco-webkit leg runs only as the macOS CI job, and CI's conformance-aggregate job re-aggregates all eight targets, diffs the committed matrices (`matrix-check`), and gates the compat registry (`compat-check --require-all`). Intentional case changes also need `just conformance-ct::lock-update` and `just conformance-ct::matrix-update` — the latter from a full run, which only CI can produce (the WebKit leg needs macOS), so in practice `just gha::update-matrices-from-ci` (copies the matrices from the branch's CI `conformance-results` artifact). | diff --git a/Cargo.lock b/Cargo.lock index af78218..ce93c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1846,6 +1846,7 @@ dependencies = [ "bytes", "futures", "futures-io", + "serde_json", "wit-bindgen 0.59.0", ] diff --git a/js/componentize/check-extension-conditions.mjs b/js/componentize/check-extension-conditions.mjs new file mode 100644 index 0000000..0c7034f --- /dev/null +++ b/js/componentize/check-extension-conditions.mjs @@ -0,0 +1,73 @@ +// The registry gate for the extension-condition table: `EXTENSION_ERRORS` +// in webcrypto.js must mirror wit/extension-conditions.json exactly — +// every registered (origin, name) pair present with the registered +// DOMException name, and no pair beyond the registry. Run by +// `just componentize::typecheck`; catches the drift the runtime cannot +// (an unlisted pair falls back to "OperationError", which the registered +// mappings currently coincide with). +// +// The table is extracted from the module source: webcrypto.js is a single +// module whose only imports are the WIT specifiers componentize-js +// resolves, so it cannot be imported here and cannot import the registry. +// The extraction is brace-matched from the declaration and fails loudly if +// the declaration moves or stops being a plain object literal. + +import { readFileSync } from "node:fs"; + +const here = (path) => new URL(path, import.meta.url); +const registry = JSON.parse(readFileSync(here("../../wit/extension-conditions.json"), "utf8")); +const source = readFileSync(here("./webcrypto.js"), "utf8"); + +const anchor = "const EXTENSION_ERRORS = "; +const start = source.indexOf(anchor); +if (start === -1) { + console.error("webcrypto.js no longer declares `const EXTENSION_ERRORS = `"); + process.exit(1); +} +const open = start + anchor.length; +if (source[open] !== "{") { + console.error("EXTENSION_ERRORS is not a plain object literal; update this gate's extraction"); + process.exit(1); +} +let depth = 0; +let end = open; +for (; end < source.length; end++) { + if (source[end] === "{") depth++; + else if (source[end] === "}" && --depth === 0) break; +} +if (depth !== 0) { + console.error("EXTENSION_ERRORS literal has unbalanced braces; update this gate's extraction"); + process.exit(1); +} +// The slice is a string-keyed, string-valued object literal (the gate's +// mismatch reporting below keeps it honest if that ever changes). +const extensionErrors = new Function(`return (${source.slice(open, end + 1)});`)(); + +const failures = []; +const registered = new Set(); +for (const condition of registry.conditions) { + const { origin, name, "dom-exception": domException } = condition; + registered.add(`${origin}\u0000${name}`); + const served = extensionErrors[origin]?.[name]; + if (served === undefined) { + failures.push(`missing: (${origin}, ${name}) — the registry maps it to ${domException}`); + } else if (served !== domException) { + failures.push( + `mismatch: (${origin}, ${name}) — the table says ${served}, the registry ${domException}`, + ); + } +} +for (const [origin, names] of Object.entries(extensionErrors)) { + for (const name of Object.keys(names ?? {})) { + if (!registered.has(`${origin}\u0000${name}`)) { + failures.push(`unregistered: (${origin}, ${name}) — not in wit/extension-conditions.json`); + } + } +} + +if (failures.length > 0) { + console.error("webcrypto.js's EXTENSION_ERRORS does not mirror wit/extension-conditions.json:"); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} +console.log(`extension conditions: ${registry.conditions.length} pairs mirror the registry`); diff --git a/js/componentize/justfile b/js/componentize/justfile index 1849611..1ace0a5 100644 --- a/js/componentize/justfile +++ b/js/componentize/justfile @@ -13,10 +13,12 @@ import '../../justfile.shared.just' # Type-check the library against the Web Cryptography API definitions -# TypeScript ships. Nothing is generated, so nothing can go stale; no -# component build. +# TypeScript ships, and check the extension-condition table against the +# package registry (wit/extension-conditions.json). Nothing is generated, +# so nothing can go stale; no component build. typecheck: npm run typecheck + node check-extension-conditions.mjs # Componentize the JS WebCrypto-subset demo guest (this library + # examples/componentize-demo app) into examples/componentize-demo/build/. diff --git a/js/componentize/webcrypto.js b/js/componentize/webcrypto.js index 8508bb0..c1937dd 100644 --- a/js/componentize/webcrypto.js +++ b/js/componentize/webcrypto.js @@ -249,18 +249,27 @@ function isWitError(e) { } /** - * The `DOMException` names for the known extension conditions, by - * (`origin`, `name`); a pair not listed here is handled as an operational - * failure (the package's rule for unrecognized pairs). The full WIT - * payload rides in the `DOMException`'s `cause`. + * The `DOMException` names for the package's named extension conditions, + * by (`origin`, `name`) pair: the WebCrypto-vocabulary mirror of the + * package registry, `wit/extension-conditions.json`. + * `check-extension-conditions.mjs` (run by `just componentize::typecheck`) + * extracts this table from the source — componentize-js resolves only the + * WIT specifiers, so this file stays a single module — and fails when the + * two drift. A pair not listed here is handled as an operational failure + * (the package's rule for unrecognized pairs). * @type {Readonly> | undefined>>} */ const EXTENSION_ERRORS = { - "polymorph:webcrypto": { "collision-detected": "OperationError" }, + "polymorph:webcrypto": { + "collision-detected": "OperationError", + "message-too-long": "OperationError", + }, }; /** * Map a WIT `types.error` variant onto the WebCrypto error vocabulary. + * Extension pairs map through `EXTENSION_ERRORS`; the full WIT payload + * rides in the `DOMException`'s `cause`. * @param {WitError} payload */ function mapWitError(payload) { diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index 716894e..f636637 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -95,3 +95,5 @@ aws-lc-rs = "1" # vectors whose hex arrives as a `&str` go through `data-encoding`. data-encoding = "2" data-encoding-macro = "0.1" +# The extension-condition registry gate parses wit/extension-conditions.json. +serde_json = "1" diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index 52895c2..c07bb6d 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -456,4 +456,40 @@ mod tests { assert_ne!(c, vec![0u8; 32]); assert_ne!(c, d); } + + /// The registry (`wit/extension-conditions.json`) is the authoritative + /// spelling of the package's extension-condition pairs: the constants + /// here — and so the constructors built from them — must match it + /// exactly, in both directions. + #[test] + fn extension_conditions_match_the_registry() { + let registry: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../wit/extension-conditions.json" + ))) + .expect("wit/extension-conditions.json parses"); + let registered: std::collections::BTreeSet<(&str, &str)> = registry["conditions"] + .as_array() + .expect("registry has a conditions array") + .iter() + .map(|condition| { + ( + condition["origin"].as_str().expect("condition origin"), + condition["name"].as_str().expect("condition name"), + ) + }) + .collect(); + let constants = std::collections::BTreeSet::from([ + (EXTENSION_ORIGIN, COLLISION_DETECTED), + (EXTENSION_ORIGIN, MESSAGE_TOO_LONG), + ]); + assert_eq!(constants, registered); + + for error in [Error::collision_detected(), Error::message_too_long(0, 1)] { + let Error::Extension(ext) = error else { + panic!("extension constructors build Error::Extension"); + }; + assert!(registered.contains(&(ext.origin.as_str(), ext.name.as_str()))); + } + } } diff --git a/rust/guest/Cargo.toml b/rust/guest/Cargo.toml index 269b201..c217db8 100644 --- a/rust/guest/Cargo.toml +++ b/rust/guest/Cargo.toml @@ -41,5 +41,9 @@ futures = { version = "0.3", default-features = false, features = ["async-await" bytes = { version = "1", optional = true, default-features = false } futures-io = { version = "0.3", optional = true } +[dev-dependencies] +# The extension-condition registry gate parses wit/extension-conditions.json. +serde_json = "1" + [lints.rust] missing_docs = "warn" diff --git a/rust/guest/src/lib.rs b/rust/guest/src/lib.rs index 4736b9a..6e7d7c0 100644 --- a/rust/guest/src/lib.rs +++ b/rust/guest/src/lib.rs @@ -263,10 +263,14 @@ pub enum Error { /// for matching against [`Error::Extension`]. pub mod extension { /// The `origin` of conditions the `polymorph:webcrypto` package defines. - pub const LANN_WEBCRYPTO: &str = "polymorph:webcrypto"; + pub const ORIGIN: &str = "polymorph:webcrypto"; /// `sha1-checked`'s collision condition: a rejecting digest's input /// carried a SHA-1 collision attack pattern. pub const COLLISION_DETECTED: &str = "collision-detected"; + /// `public-encryption`'s plaintext-bound condition: the plaintext (or + /// wrapped serialization) exceeds the key's bound — the signal to + /// switch to hybrid wrapping. + pub const MESSAGE_TOO_LONG: &str = "message-too-long"; } impl From for Error { @@ -1455,8 +1459,9 @@ impl EncryptionKey { /// bound into the padding: decryption succeeds only under the same /// label (WebCrypto's `RsaOaepParams.label`). A plaintext above the /// key's bound fails [`Error::Extension`] (origin `"polymorph:webcrypto"`, - /// name `"message-too-long"`) — the signal to switch to hybrid - /// wrapping: encrypt a symmetric key, wrap the payload under it. + /// name `"message-too-long"`; see [`crate::extension`]) — the signal to + /// switch to hybrid wrapping: encrypt a symmetric key, wrap the payload + /// under it. pub async fn encrypt( &self, label: Option<&[u8]>, @@ -2194,4 +2199,36 @@ mod tests { assert!(read_error().to_string().contains("reader failed")); assert!(Error::ShortWrite.to_string().starts_with("short write")); } + + /// The registry (`wit/extension-conditions.json`) is the authoritative + /// spelling of the package's extension-condition pairs: the + /// [`crate::extension`] constants consumers match against must cover it + /// exactly, in both directions. + #[test] + fn extension_constants_match_the_registry() { + let registry: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../wit/extension-conditions.json" + ))) + .expect("wit/extension-conditions.json parses"); + let registered: std::collections::BTreeSet<(&str, &str)> = registry["conditions"] + .as_array() + .expect("registry has a conditions array") + .iter() + .map(|condition| { + ( + condition["origin"].as_str().expect("condition origin"), + condition["name"].as_str().expect("condition name"), + ) + }) + .collect(); + let constants = std::collections::BTreeSet::from([ + ( + crate::extension::ORIGIN, + crate::extension::COLLISION_DETECTED, + ), + (crate::extension::ORIGIN, crate::extension::MESSAGE_TOO_LONG), + ]); + assert_eq!(constants, registered); + } } diff --git a/wit/README.md b/wit/README.md index 1b279ea..9403592 100644 --- a/wit/README.md +++ b/wit/README.md @@ -204,12 +204,20 @@ pair, which is a behavioral change for the producing implementation but never a type change. **`extension(extension-error)` carries named conditions outside the closed -set.** The closed cases are the conditions the *generic kinds'* contracts -name — universal across operation families; `extension` carries algorithm- -and feature-specific conditions, identified by the (`origin`, `name`) pair -and defined by the interface that produces them (the first is -`sha1-checked`'s `("polymorph:webcrypto", "collision-detected")`). The record's -fields have two fixed roles: +set — all of them, from here on.** The closed set is frozen: it is the set +of conditions the package's contracts named when the `error` variant was +designed — a historical artifact, not a tier of generality — and it never +grows again, because a new closed case is a semver-major change (the +variant sits in return position, where variant growth has no compatible +path). Every named condition since, kind-level and algorithm-level alike, +is an extension pair under `"polymorph:webcrypto"`, identified by the +(`origin`, `name`) pair and defined by the interface whose contract says +when it occurs — `sha1-checked`'s `("polymorph:webcrypto", +"collision-detected")`, `public-encryption`'s kind-level +`("polymorph:webcrypto", "message-too-long")`. One boundary is absolute: +failed verification reports `authentication-failed` and nothing else +(above), so no extension condition may ever carry a verification verdict. +The record's fields have two fixed roles: - the (`origin`, `name`) **pair** is the condition's only branchable identity; @@ -231,7 +239,11 @@ an opaque namespace owned by the defining party (by convention its package name; this package defines all of its conditions under `"polymorph:webcrypto"`). Third-party providers mint conditions under their own `origin`. SDKs expose constants for known pairs, and the conformance -suites pin exact pairs cross-implementation. +suites pin exact pairs cross-implementation. The pairs this package +defines are recorded in +[`extension-conditions.json`](extension-conditions.json): the +authoritative spelling, which the SDK constants and the implementations' +mapping tables are gated against. **Verification returns `result<_, error>`, not `bool`.** An ignored boolean fails open; a dropped `result` does not. diff --git a/wit/extension-conditions.json b/wit/extension-conditions.json new file mode 100644 index 0000000..505c982 --- /dev/null +++ b/wit/extension-conditions.json @@ -0,0 +1,30 @@ +{ + "doc": [ + "The named extension conditions the polymorph:webcrypto package defines:", + "one entry per (origin, name) pair. This file is the authoritative", + "spelling — implementation constants and mapping tables are gated", + "against it (see README.md, 'Error contract').", + "", + "`interface` names the defining WIT interface, whose contract says when", + "the condition occurs. `dom-exception` is the DOMException name the", + "pair maps onto in WebCrypto-vocabulary consumers. `summary` describes", + "the condition; producers' `message` strings remain diagnostics, never", + "contract." + ], + "conditions": [ + { + "origin": "polymorph:webcrypto", + "name": "collision-detected", + "interface": "sha1-checked", + "dom-exception": "OperationError", + "summary": "A rejecting SHA-1 digest's input carried a collision attack pattern." + }, + { + "origin": "polymorph:webcrypto", + "name": "message-too-long", + "interface": "public-encryption", + "dom-exception": "OperationError", + "summary": "The plaintext (or wrapped serialization) exceeds the key's bound: the signal to switch to hybrid wrapping." + } + ] +}