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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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). |
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions js/componentize/check-extension-conditions.mjs
Original file line number Diff line number Diff line change
@@ -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`);
6 changes: 4 additions & 2 deletions js/componentize/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/.
Expand Down
19 changes: 14 additions & 5 deletions js/componentize/webcrypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, Readonly<Record<string, string>> | 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) {
Expand Down
2 changes: 2 additions & 0 deletions rust/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
36 changes: 36 additions & 0 deletions rust/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())));
}
}
}
4 changes: 4 additions & 0 deletions rust/guest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
43 changes: 40 additions & 3 deletions rust/guest/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bindings::types::Error> for Error {
Expand Down Expand Up @@ -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]>,
Expand Down Expand Up @@ -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);
}
}
26 changes: 19 additions & 7 deletions wit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions wit/extension-conditions.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
Loading