@emilia-protocol/verify
v3.8.1
Published
Zero-dependency offline verification for EP authorization receipts: Ed25519 receipts, Merkle anchors, commitment proofs, Class-A WebAuthn device signoffs, multi-party quorum (M-of-N / ordered, two-person rule), portable revocation, trusted-time attestatio
Downloads
3,417
Maintainers
Readme
@emilia-protocol/verify
Zero-dependency offline verification for EP trust receipts.
Verify Ed25519-signed trust receipts, Merkle anchor proofs, and commitment proofs using only Node.js built-in crypto. No EP infrastructure required. No API key. No account. Just math.
This is the core primitive that makes EP a protocol, not an API.
Install
npm install @emilia-protocol/verifyQuick Start
import { verifyReceipt } from '@emilia-protocol/verify';
// Load a receipt document (EP-RECEIPT-v1 format)
const receipt = JSON.parse(fs.readFileSync('receipt.json', 'utf8'));
// Get the signer's public key (from /.well-known/ep-keys.json)
const publicKey = 'MFYwEAYHKoZIzj0CAQYFK4EEAA...'; // base64url SPKI DER
const result = verifyReceipt(receipt, publicKey);
console.log(result);
// { valid: true, checks: { version: true, signature: true, anchor: null } }In the browser, edge, or Deno
The default entry uses Node's crypto. For any runtime with the W3C Web Crypto
API — every modern browser, Deno, Cloudflare Workers, Vercel Edge — import the
/web build instead. Same inputs, same { valid, checks } output (proven
byte-for-byte in web.test.js); the functions are async because Web Crypto is.
import { verifyReceipt, verifyWebAuthnSignoff } from '@emilia-protocol/verify/web';
const r = await verifyReceipt(receipt, publicKey); // Ed25519
const s = await verifyWebAuthnSignoff(signoff, approverKey, // ECDSA P-256
{ rpId: 'emiliaprotocol.ai' });This is what powers emiliaprotocol.ai/verify:
a relying party verifies a receipt entirely in their own tab — nothing uploaded,
no server trusted. Receipts use Ed25519; Class-A device signoffs use ECDSA P-256
over a WebAuthn assertion (the /web build converts the DER signature to the raw
form Web Crypto expects). Call isSupported() to feature-detect.
API
verifyReceipt(doc, publicKeyBase64url)
Verify an EP-RECEIPT-v1 document. Performs three independent checks:
- Version — Document format is EP-RECEIPT-v1
- Signature — Ed25519 signature over canonical payload
- Anchor (if present) — Merkle proof reconstructs claimed root
Returns { valid, checks, error? }.
verifyMerkleAnchor(leafHash, proof, expectedRoot)
Verify a Merkle inclusion proof. The root can be independently checked on Base L2 via Basescan.
Returns boolean.
verifyCommitmentProof(proof, publicKeyBase64url)
Verify an EP-PROOF-v1 commitment proof. Checks expiry and signature.
Returns { valid, claim, error? }.
verifyReceiptBundle(bundle, publicKeyBase64url)
Verify all receipts in an EP-BUNDLE-v1 document.
Returns { valid, total, verified, failed }.
verifyWebAuthnSignoff(signoff, approverPublicKeySpkiB64u, { rpId? })
Verify a Class-A (device-bound key) signoff fully offline: the WebAuthn challenge equals SHA-256(JCS(context)) for the exact signed context, the authenticator asserted user presence + verification, and the ECDSA P-256 signature verifies against the enrolled approver key.
Returns { valid, checks, error? }.
verifyTrustReceipt(receipt, { approverKeys, logPublicKey }) — requires 1.3.0
The full offline verification algorithm from the Internet-Draft (draft-schrock-ep-authorization-receipts, Section 6.3) over a Section 6.2 Trust Receipt — all six steps, no network:
- Recompute the action hash from the canonical Action Object
- Recompute each context hash; confirm it commits to the action hash, the policy hash, and a distinct approver
- Verify each signoff signature (Class-A WebAuthn or Class-B Ed25519) against the pinned approver key, checking the key's validity window
- Separation of duties — initiator in no approver slot, approvers pairwise distinct, approval count ≥
required_approvals - Merkle inclusion of the receipt leaf against the checkpoint root, and the checkpoint signature against the trusted log key
signed_at/committed_atwithin[issued_at, expires_at]
Returns { valid, checks, errors, attestation, strict } and fails closed on any missing input.
Strict verifier mode — requires 1.5.0
For deployment gates and hostile-environment verification, opt into strict mode:
const r = verifyTrustReceipt(receipt, {
approverKeys,
logPublicKey,
strict: true,
rpId: 'www.emiliaprotocol.ai',
expectedPolicyHash: 'sha256:...',
});Strict mode preserves the frozen Section 6.3 checks object, then adds
r.strict as a second gate. When strict: true, valid requires both the base
checks and:
pinned_keys— every signer and the log are locally pinned.rp_id— Class-A WebAuthnrpIdHashmatches the caller-pinned RP ID.user_presence/user_verification— Class-A signoffs asserted UP + UV.key_windows— every approver key has parseablevalid_from/valid_toand was valid atissued_at.policy_hash— every context matchesexpectedPolicyHash.no_unsigned— critical action, context, signoff, consumption, and log proof fields are present.
Without strict: true, strict is { enabled: false, valid: true, checks: {}, errors: [] }, so existing verification and conformance semantics are unchanged.
Advisory: the PIP-007 initiator escalation attestation — requires 1.4.0
When the contexts carry a PIP-007 initiator_attestation, the result includes an advisory report:
const r = verifyTrustReceipt(receipt, { approverKeys, logPublicKey });
r.attestation; // { present, consistent, issues: [] }present— a context carries an attestation.consistent— it is present in every context with an identical canonical form (the cross-context identity rule the protocol flags to catch a divide-and-misinform orchestrator showing different approvers different reasons).issues— any PIP-007 §1 malformations: unknown members, astatementover 280 characters,escalation_triggerofpolicy_rulewithout apolicy_basis, or a bad enum value.
The advisory never affects valid or any member of checks — by design (PIP-007 §2): a receipt carrying a malformed attestation still verifies cryptographically, exactly as it does on a verifier that predates this PIP. The attestation is a claim by the initiator — identified but never trusted — so a policy engine MUST NOT use it to relax any check or raise any trust score.
Opt-in transparency and currency knobs (requires 3.5.0)
Five additive, opt-in checks extend verifyTrustReceipt in the same shape as priorCheckpoint: each runs only when you pass its option, adds one member to checks when active, folds into valid by conjunction, and fails closed with a distinct reason. Pass none of them and the result is byte-for-byte what a pre-3.5.0 verifier returns (the frozen seven checks members, no extra top-level members).
const r = verifyTrustReceipt(receipt, {
approverKeys, logPublicKey,
// 1. Witness quorum (EP-WITNESS-v1): k distinct pinned witnesses cosigned the head.
witnessQuorum: { cosignatures, pinnedWitnessKeys, k: 2 },
// 2. Trusted-time proof (RFC 3161): a pinned TSA timestamped a digest you choose.
timestampProof: { token, expectedDigest, pinnedTsaKeys },
// 3. Currency (EP-CURRENCY-v1): passes ONLY on a proven-fresh signed head.
currency: { now, maxStalenessSeconds, freshHead, freshHeadRequired },
// 4. Consumption proof (EP-SMT-CONSUME-v1): a nonce went absent -> present once.
consumptionProof: bundle,
// 5. Initiator-software attestation (EP-INITIATOR-ATTESTATION-v1).
requireInitiatorAttestation: true,
});
// checks.witness_quorum / .timestamp_proof / .currency / .consumption /
// .initiator_attestation are added only for the options you passed, and the
// full module result is surfaced under the matching top-level member.Honesty boundaries (also stated in each module):
- Witness quorum proves
ktrusted witnesses saw one head (the local, single-view half of equivocation detection). It does not prove no different head was shown elsewhere; that cross-view gossip is the deployment's responsibility. - Timestamp proof proves a TSA asserted the digest existed at
gen_time(the bytes predategen_time). It is authentic-as-of-token only and says nothing about current TSA-certificate validity or revocation, and it does not prove the action was correct or authorized. - Currency is a separate axis from offline authenticity.
checks.currencypasses only on statusfresh; bothstaleand the honest offline defaultunknownfail the opted-in gate, because offline verification can never establish currency. Readresult.currency.currency_at_Tto tellunknown(offline only) apart fromstale. - Consumption proof proves the tree-shaped consumption facts only. Checkpoint signatures and currency of the later head are the caller's responsibility.
- Initiator attestation says which software asked; it does not prove the software behaved (the labels are self-asserted, and the digest is authentic-as-supplied, not proof of correct execution).
Both the witness and consumption profiles now ship a verifier and a reference emitter, so the emit/verify loop is closed at reference level. A third party can PRODUCE these artifacts, not only check them:
- Witness (EP-WITNESS-v1). The reference witness emitter is the cosigner service in
witness/(witness/server.mjs). It imports the signing digest and domain tag from this package (witness.js), so a cosignature it emits is byte-identical to whatverifyWitnessCosignature()/requireWitnessQuorum()check. - Consumption (EP-SMT-CONSUME-v1). The reference issuer-side emitter is
ReferenceConsumptionTreeinconsumption-proof.js, exported as@emilia-protocol/verify/consumption-proof.js. It maintains the sparse consumption tree and emits the non-inclusion / inclusion sub-proofs in the exact wire formatverifyConsumptionProof()accepts, so anyone can reproduce a full bundle.
Reference emitters pin the wire format; they are not production infrastructure. A production issuer maintains its own sparse consumption ledger (not the in-memory reference tree), and the security of the witness leg comes from RUNNING several independent witnesses under separate operators and comparing their views. That ecosystem step is deployment, not reference code.
All five of these profiles (EP-WITNESS-v1, EP-CURRENCY-v1, EP-SMT-CONSUME-v1, EP-INITIATOR-ATTESTATION-v1, and timestamp proof (RFC 3161)) are now ported to Python (packages/python-verify) and Go (packages/go-verify) and run cross-language in conformance/run.mjs over shared vector suites (currency.v1.json, initiator-attestation.v1.json, consumption-proof.v1.json, witness.v1.json, timestamp-proof.v1.json), where the JavaScript, Python, and Go verifiers must agree. The RFC 3161 timestamp-proof ports keep the package's dependency posture: the JS minimal DER/CMS reader was hand-ported to pure Python (with cryptography used only for the RSA/ECDSA signature verify, so no new dependency) and to pure-stdlib Go, and all three lanes agree over real openssl-minted TimeStampTokens, including the exact per-vector refusal path. As always, this is one team's three-language ports (a consistency check), not clean-room independent implementations.
Federation (PIP-006) — requires 1.3.0
Cross-operator verification: accept a receipt issued by a different EP operator using only its published discovery surfaces.
import { verifyFederatedReceipt, verifyFederatedReceiptOffline } from '@emilia-protocol/verify';
// Online: resolves the issuer's keys from a caller-pinned discovery URL and
// checks its revocation surface. Treat receipt.signature.key_discovery as a
// hint, not a trust root.
const verdict = await verifyFederatedReceipt(receipt, {
keyDiscoveryUrl: 'https://op-a.example/.well-known/ep-keys.json',
});
// { accepted, verified, revoked, signer, keyMatched: 'current'|'historical', checks }
// Air-gapped: supply the issuer's ep-keys.json + revocation set yourself.
const offline = verifyFederatedReceiptOffline(receipt, discoveryDoc, { revokedReceiptIds });resolveOperatorKeys(discoveryDoc, signerId) is also exported (current keys
first, then historical_keys for rotation safety). See
docs/FEDERATION-REGISTRY.md for the operator discovery convention.
Design Principles
- Zero dependencies — Only
node:crypto. No supply chain risk. - Offline-first — No network calls (the federation online path takes an injectable
fetch). No EP server needed. - Deterministic — Canonical JSON serialization for reproducible signatures.
- Auditable — A few small files, ~1,000 lines total. Read the entire thing in an hour.
How It Works
Receipt Document (EP-RECEIPT-v1)
├── payload (canonical JSON)
├── signature
│ ├── algorithm: "Ed25519"
│ ├── signer: "ep_entity_..."
│ └── value: base64url signature
└── anchor (optional)
├── leaf_hash: SHA-256 of receipt
├── merkle_proof: [{hash, position}, ...]
├── merkle_root: root hash
└── chain: "base-sepolia"
Verification:
1. Canonicalize payload → sorted-key JSON
2. Verify Ed25519(canonical_payload, signature, public_key)
3. If anchor: reconstruct Merkle root from proof, compareGetting Public Keys
Signer public keys are discoverable at /.well-known/ep-keys.json on any EP operator:
curl https://ep.example.com/.well-known/ep-keys.jsonReliance gap reports (acceptance preflight)
reliance-gap.js wraps the reliance kernel (reliance.js) into a diagnostic:
given a de-identified action packet and a relying party's pinned
EP-RELIANCE-PROFILE-v1, it emits one deterministic EP-RELIANCE-GAP-REPORT-v1
with the kernel verdict passed through verbatim, a missing-evidence list
(each entry: requirement, why it matters, how to close it), the JCS+sha256
action digest, the pinned profile digest, a plain-language control mapping
(authority, identity, freshness, revocation, consumption, signoff, audit
trail), a closed limitations list, and the exact command that reproduces the
report offline.
import { buildRelianceGapReport } from '@emilia-protocol/verify/reliance-gap';
const report = buildRelianceGapReport(
{ action, evidence, context }, // the packet
profile, // the relying party's pinned rule
{ now: '2026-07-08T15:00:00Z' }, // evaluation time (never the wall clock)
);The packet's evidence is an array of artifacts, either { type, artifact }
envelopes or bare artifacts detected by shape (receipt, quorum,
authority_proof, revocation_state, consumption). Artifact types with no
registered verifier are recorded as unverifiable_present and never count
toward satisfaction. The packet's context carries the relying party's
verification material: approver_keys, log_public_key, rp_id,
revoker_keys. The profile argument accepts a bare profile or a signed
EP-RELIANCE-PROFILE-REGISTRY-v1 entry (unwrapped; the entry's profile_id is
reported).
Determinism contract: no wall-clock reads (evaluation time comes only from
opts.now or packet.evaluated_at; absent both, the builder refuses with a
reason), keys sorted, arrays stable, so the same inputs reproduce the same
bytes. buildMultiPartyRelianceGapReport evaluates the SAME packet against
several profiles and emits one combined EP-RELIANCE-GAP-MULTI-v1 report.
From the CLI:
npx @emilia-protocol/verify reliance-gap packet.json --profile profile.json
npx @emilia-protocol/verify reliance-gap packet.json --profiles ./profiles \
--now 2026-07-08T15:00:00Z --out report.jsonExit codes: 0 = rely (all rely in --profiles mode), 2 = any
do_not_rely_*, 1 = operational error. Fully offline; no network access.
A worked five-relying-party example lives in examples/reliance-gap/ at the
repository root. A single gap report is the per-action preflight;
EP-ASSURANCE-PACKAGE-v1 (packages/gate/reports/assurance-package.js)
bundles a population of such reliance decisions so an independent assurer can
re-perform every verdict offline.
License
Apache-2.0
