@agentcontextdistributionprotocol/acdp-wasm
v0.14.2
Published
Agent Context Distribution Protocol — WebAssembly verification core
Readme
acdp-wasm — ACDP WebAssembly verification core
The browser / edge / WASI member of the ACDP binding family
(bindings/acdp-py, bindings/acdp-node). A pure, offline
cryptographic verifier: it lets a consumer render an ACDP context and
independently reach a real verification verdict — the producer
signature, the content_hash, a registry receipt (RFC-ACDP-0010), a
lineage-head receipt (RFC-ACDP-0011), a transparency-log checkpoint /
inclusion / consistency proof (RFC-ACDP-0012), a lifecycle event
(RFC-ACDP-0013), a key revocation (RFC-ACDP-0014), and witness
cosignatures + quorum (RFC-ACDP-0015) — without trusting any server to
have done it. This is the client-side verification core the console's
verdicts consume.
It is a standalone Cargo package (its own [workspace]) that depends on
the umbrella acdp crate with default-features = false, so
reqwest / tokio / rustls never enter the .wasm binary. See
docs/research/wasm-target.md for the design rationale.
Design (same rules as the Python / Node bindings)
- JSON across the boundary. Every export takes JSON strings and
returns a JSON string — a verdict object (
{"valid": true, ...}/{"valid": false, "code"?, "error"}) for verification outcomes, or a result string for constructors/resolvers. Malformed host input throws aJsError; a failed verification is{"valid": false}, never a throw. - Crypto in Rust, HTTP in the host. No network calls.
did:webresolution and all transport stay in JS (fetchthe DID document / receipt / body, pass the JSON in).did:keyverification is fully offline and needs no host help — the highest-value browser path. - No crypto reimplemented. Every check delegates to the same
acdpcore the native library, Python, and Node bindings use. The 0.3/0.4 verdict logic (src/v030.rs,src/v040.rs) is lifted verbatim, byte-identical from the other bindings.
Exported surface
wasm-bindgen exports (camelCase in JS/TypeScript):
| Export | RFC | Purpose |
|---|---|---|
| verifyContentHash | 0001 §5.7 | recompute sha256(JCS(producer_content)) |
| verifySignatureEd25519 / verifySignatureP256 | 0001 §5.8 | signature over the ASCII "sha256:<hex>" string |
| verifyCtxIdBinding | 0006 §4.1 step 7 | bind the served ctx_id to the one requested — see Verifying a retrieved body |
| verifyBodyOffline / verifyPublishRequestOffline | 0002 | full did:key context verify (no resolution) |
| fingerprintEd25519 / verifyReceipt(receiptJson, bodyJson, registryPublicKeyB64, expectedCtxId, recomputedBodyHash, producerKeyFingerprint) | 0010 | registry-receipt verification — see Verifying a registry receipt below |
| verifyLineageHeadReceipt | 0011 | lineage-head receipt |
| verifyLogCheckpoint / verifyLogInclusion / verifyLogConsistency / buildLogLeaf / merkleLeafHash / merkleNodeHash / merkleRootHash | 0012 | transparency log |
| verifyLifecycleEvent | 0013 | lifecycle event |
| parseKeyRevocation / classifyUnderRevocation | 0014 | key revocation |
| buildWitnessCosignature / verifyWitnessCosignature / evaluateWitnessQuorum | 0015 | witness cosignatures + quorum |
| resolveDidKey | — | offline did:key → public key |
| canonicalPreimage / explainHashMismatch | — | hash-divergence diagnostics |
Install
Published to npm as @agentcontextdistributionprotocol/acdp-wasm (a
public scoped package, provenance-signed on GitHub Actions), so the console
and other consumers depend on it without a sibling checkout:
npm install @agentcontextdistributionprotocol/acdp-wasmimport init, { verifyContentHash, verifySignatureEd25519, verifyCtxIdBinding }
from "@agentcontextdistributionprotocol/acdp-wasm";
await init();
const verdict = JSON.parse(verifyContentHash(bodyJson, body.content_hash));
if (verdict.valid) { /* the hash the consumer recomputed itself checks out */ }
// Bind the served ctx_id to the one requested (RFC-ACDP-0006 §4.1 step 7).
// See "Verifying a retrieved body" below for why this is a separate call.
const binding = JSON.parse(verifyCtxIdBinding(bodyJson, requestedCtxId));
if (binding.valid) { /* the ctx_id you got back is the one you asked for */ }Verifying a retrieved body
ctx_id is registry-assigned and sits in the RFC-ACDP-0001 §5.7 exclusion
set, so it is stripped before content_hash is computed — neither the hash
recompute above nor the producer's signature covers it. Without an
explicit comparison, a registry could serve any other validly-signed body
from the same producer under the ctx_id URL you asked for, and every
check above would still pass. verifyCtxIdBinding(bodyJson, expectedCtxId)
closes that gap: bodyJson carries the served identity, expectedCtxId
is what you requested. Like verifyContentHash, a malformed served
ctx_id is a {"valid": false, ...} verdict, not a throw — only a
malformed expectedCtxId argument throws a JsError.
Verifying a registry receipt
verifyReceipt (RFC-ACDP-0010 §8) takes the accompanying bodyJson as a
required second argument — it binds the receipt's lineage_id,
origin_registry, and created_at to the served body's own fields (§8
step 3):
const verdict = JSON.parse(verifyReceipt(
receiptJson, bodyJson, registryPublicKeyB64,
expectedCtxId, recomputedBodyHash, producerKeyFingerprint,
));A malformed bodyJson throws a JsError (host input); a body/receipt
mismatch is a {"valid": false, ...} verdict (verification outcome), not
a throw — same convention as verifyContentHash / verifyCtxIdBinding.
Two checks still stay the HOST's obligation, because neither needs the
body: the serving-authority binding (receipt.registry_did must equal
the authority you actually fetched from) and recomputing (never
trusting) the body hash you pass in.
The package is built with wasm-pack --target web: an ESM module you
initialize once with await init(). This is the target the crate is
designed and CI-tested around, and it loads cleanly in browsers and in
Next.js client components (webpack 5 resolves the new URL(..., import.meta.url)
wasm asset the web target emits — no experiments.asyncWebAssembly
webpack override, which the bundler target would require).
Build
rustup target add wasm32-unknown-unknown
# Raw wasm (regression build-check):
cargo build --target wasm32-unknown-unknown # verify-only default
# Browser package (.js / .wasm / .d.ts) with wasm-pack. `--scope` makes
# wasm-pack emit the package name `@agentcontextdistributionprotocol/acdp-wasm`
# (the same rename the release workflow performs):
wasm-pack build --target web --out-dir pkg --scope agentcontextdistributionprotocolImport from a browser (local, unpublished build):
import init, { verifyContentHash, verifySignatureEd25519, verifyCtxIdBinding } from "./pkg/acdp_wasm.js";
await init();
const verdict = JSON.parse(verifyContentHash(bodyJson, body.content_hash));
if (verdict.valid) { /* the hash the consumer recomputed itself checks out */ }
const binding = JSON.parse(verifyCtxIdBinding(bodyJson, requestedCtxId));
if (binding.valid) { /* the ctx_id you got back is the one you asked for */ }Golden-vector parity
tests/golden.rs is a native cargo test that runs the canonical
sig-001 (content hash + Ed25519 signature) and wit-001 (witness
cosignature mint + verify) spec fixtures through the same pure core
functions the wasm exports wrap, asserting byte-for-byte reproduction of
the pinned golden values — the same constants the acdp-py / acdp-node
suites pin. It locates fixtures via ACDP_SPEC_DIR and skips gracefully
when absent:
ACDP_SPEC_DIR=../../../agentcontextdistributionprotocol cargo testtests/wasm.rs is a wasm-bindgen-test real-engine smoke test of the
exported wrappers (inline sig-001 constants, no fixtures needed):
wasm-pack test --nodeRandomness — a correction to the research memo
docs/research/wasm-target.md §4 predicted a verify-only build would
need no getrandom backend on any wasm target. That holds at
runtime — no exported verification (nor the deterministic Ed25519
witness mint) draws randomness. It does not hold at compile time
for wasm32-unknown-unknown: the core crates pull two randomness sources
unconditionally, and each emits a hard compile_error! on that
target unless a backend is wired:
getrandom 0.2viarand_core 0.6/OsRng(a non-optional dependency ofacdp-crypto) → enabled with the getrandomjsfeature.getrandom 0.4viauuidv4 (a non-optional dependency ofacdp-primitives) → enabled with the getrandomwasm_jsfeature plus--cfg getrandom_backend="wasm_js"(set in.cargo/config.toml).uuidadditionally needs its ownjsfeature for its v4 RNG shim.
All three are wired here, target-gated to wasm32 only (see
Cargo.toml and .cargo/config.toml), so a native cargo test is
untouched. crypto.getRandomValues (the js backend) is the CSPRNG
RFC-ACDP-0001 §5.10 names for the browser, so the choice is spec-blessed
— but it is never invoked on the verify path; it is present only to link.
A future producer feature (reserved, not yet wired) would expose fresh
key generation (SigningKey::generate, the only OsRng caller) and is
where a runtime randomness draw would actually occur.
Security notes
- This artifact is a verifier, not a fetcher.
RegistryClient,CrossRegistryResolver, anddid:webresolution do NOT move to wasm; the host owns HTTP and the RFC-ACDP-0006 §7 / RFC-ACDP-0008 SSRF defenses. Pass fetched documents in as JSON. - A browser tab is not an HSM. Verification holds no secrets, but a
future
producer/keygen surface would place the signing seed in wasm linear memory during a call (RFC-ACDP-0001 §5.10).
