diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a53603..46321a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,32 @@ jobs: bun-version: 1.3.14 - run: bun install --frozen-lockfile - run: bun run check + - name: Compile the TypeScript consumer example + run: bun run tsc --noEmit --strict --module ESNext --moduleResolution Bundler --target ES2022 --lib ES2022,DOM,DOM.Iterable --types node examples/verify.ts + - name: Build the browser ESM bundle + run: bun build ./src/index.ts --target=browser --format=esm --outfile="$RUNNER_TEMP/open-receipt.browser.js" - run: npm pack --dry-run + + node-consumer: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + package-manager-cache: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + - name: Pack and install as a Node consumer + run: | + npm pack --pack-destination "$RUNNER_TEMP" + mkdir "$RUNNER_TEMP/consumer" + cd "$RUNNER_TEMP/consumer" + npm init --yes + npm install "$RUNNER_TEMP"/receiptprotocol-open-receipt-*.tgz + node --input-type=module --eval 'import { canonicalize, verifyOpenReceiptTrust } from "@receiptprotocol/open-receipt"; if (canonicalize({b: 2, a: 1}) !== "{\"a\":1,\"b\":2}" || typeof verifyOpenReceiptTrust !== "function") process.exit(1)' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7310a39..63fb5af 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,5 +41,26 @@ jobs: run: bun run build - name: Inspect package tarball run: npm pack --dry-run + - name: Check exact version on npm + id: registry + shell: bash + run: | + package_name=$(node -p 'require("./package.json").name') + package_version=$(node -p 'require("./package.json").version') + set +e + npm_output=$(npm view "${package_name}@${package_version}" name version dist.integrity --json 2>&1) + npm_status=$? + set -e + if [ "$npm_status" -eq 0 ]; then + PACKAGE_NAME="$package_name" PACKAGE_VERSION="$package_version" PUBLISHED_METADATA="$npm_output" node -e 'const value = JSON.parse(process.env.PUBLISHED_METADATA); if (value.name !== process.env.PACKAGE_NAME || value.version !== process.env.PACKAGE_VERSION || typeof value["dist.integrity"] !== "string") throw new Error("Published npm metadata does not match the release package")' + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Verified ${package_name}@${package_version}; npm publication will be skipped." + elif grep -q "E404" <<<"$npm_output"; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "$npm_output" >&2 + exit "$npm_status" + fi - name: Publish public package with Trusted Publishing + if: steps.registry.outputs.exists != 'true' run: npm publish --access public --tag latest diff --git a/CHANGELOG.md b/CHANGELOG.md index e41d27b..e9b3488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.1 - 2026-08-02 + +- Accept both canonical `sha256:` parent references and immutable + historical bare SHA-256 parent references. + +## 0.2.0 - 2026-07-30 + +- Add issuer trust resolution through embedded, pinned-metadata, and HTTPS + WebPKI modes. +- Add signed, versioned issuer metadata. +- Add purpose-bound key lifecycle and historical verification. +- Add issuance attestations and signed issuance-log checkpoints. +- Preserve v0.1 APIs, schemas, vectors, and verification behavior. + ## 0.1.0 - 2026-07-21 - Publish Receipt Evidence Specification v0.1, explicit JSON Schemas, public diff --git a/README.md b/README.md index 54c0b88..5dc676f 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,88 @@ -# Open Receipt v0.1 verifier +# Open Receipt 0.2 -`@receiptprotocol/open-receipt` canonicalizes and verifies Receipt Evidence -Specification v0.1 events locally. Verification does not call Receipt's API. +Open Receipt is an open, versioned specification for portable, verifiable +commercial evidence. + +Open Receipt 0.2 adds issuer trust resolution, purpose-bound key lifecycle, +historical verification, and issuance attestations while remaining backward +compatible with v0.1. + +Install the current verifier: + +```sh +npm install @receiptprotocol/open-receipt@0.2.1 +``` + +## Trust-aware verification ```ts -import { verifyOpenReceipt, verifyOpenReceiptBundle } from "@receiptprotocol/open-receipt"; +import { + createHttpsWebPkiResolver, + verifyOpenReceiptTrust, +} from "@receiptprotocol/open-receipt"; + +const resolver = createHttpsWebPkiResolver({ + allowedIssuerOrigins: ["https://receiptprotocol.com"], +}); + +const result = await verifyOpenReceiptTrust(receipt, { + trustMode: "https_webpki", + resolver, +}); -const result = await verifyOpenReceipt(receipt, { issuerMetadata: cachedMetadata }); -if (!result.valid) throw new Error(result.errors.join(", ")); +if (!result.signature.valid || !result.issuer.trusted) { + throw new Error(result.errors.join(", ")); +} ``` -An embedded public JWK is sufficient to prove that the document was signed by -the corresponding private key. It is not, by itself, proof of the issuer's -identity. Pass cached issuer metadata obtained through a trusted path to pin the -issuer identity and set `issuer_identity_trusted` to `true`. +Cryptographic signature validity and issuer identity trust are reported +separately. The verifier supports three trust modes: -The event is canonicalized with RFC 8785 JSON Canonicalization Scheme rules and -signed as a detached compact JWS using Ed25519 (`alg: EdDSA`). Parent event -hashes are SHA-256 digests of complete signed parent envelopes. +- `embedded_only` verifies the signature against the embedded public key but + does not establish a trusted issuer identity. +- `pinned_metadata` verifies against an explicitly trusted signed issuer + metadata snapshot and hash, including offline use. +- `https_webpki` resolves the exact signed metadata version from an allowlisted + HTTPS issuer origin and validates its hash chain. -Open Receipt v0.1 is an early open specification, not an adopted industry -standard. `validated` means a bound validator ran and passed for that event; it -does not mean a permanent certification or universal guarantee. +## Issuer trust and key lifecycle + +Open Receipt 0.2 issuer metadata is signed, versioned, immutable, and +hash-chained. Public keys are bound to one purpose, including +`open_receipt_evidence`, `issuer_metadata`, and `issuance_log`. + +Lifecycle states distinguish preactive, active, retired, revoked, compromised, +and destroyed keys. Historical verification evaluates the key state at the +Receipt's issuance time. Retired public keys remain available for verification; +revocation and compromise are evaluated from their effective timestamps. + +## Issuance attestations and checkpoints + +An optional issuance attestation binds a Receipt digest, evidence signing key, +issuance time, and append-only sequence under a separate issuance-log key. +Signed checkpoints commit to the current log position and hash. Together they +provide stronger evidence against fraudulent backdating after an evidence-key +compromise without exposing private commercial payloads. + +## v0.1 compatibility + +The original `verifyOpenReceipt` and `verifyOpenReceiptBundle` APIs, v0.1 +schemas, and deterministic vectors remain supported. Existing v0.1 Receipts +are not rewritten or re-signed. A v0.1 Receipt without an issuance attestation +remains cryptographically verifiable with lower trust assurance. ## Repository contents -- [`SPECIFICATION.md`](./SPECIFICATION.md): Receipt Evidence Specification v0.1. -- [`schemas`](./schemas): explicit event, bundle, and issuer JSON Schemas. -- [`test-vectors`](./test-vectors): public valid, invalid, and compatibility vectors. -- [`src`](./src): dependency-free TypeScript verifier source. -- [`examples`](./examples): offline verification example. +- [`SPECIFICATION.md`](./SPECIFICATION.md) — Open Receipt 0.2 specification and + v0.1 compatibility rules. +- [`schemas`](./schemas) — v0.1 and v0.2 JSON Schemas. +- [`test-vectors`](./test-vectors) — preserved v0.1 vectors and the 30-case + deterministic v0.2 trust catalog. +- [`src`](./src) — dependency-free TypeScript verifier source. +- [`examples`](./examples) — trust-aware verification example. Run `bun install --frozen-lockfile` and `bun run check` to type-check, test, and build the package. + +Learn more at [receiptprotocol.com/open-receipt](https://receiptprotocol.com/open-receipt) +or view the package on [npm](https://www.npmjs.com/package/@receiptprotocol/open-receipt). diff --git a/SPECIFICATION.md b/SPECIFICATION.md index 3c94f36..7f31e5d 100644 --- a/SPECIFICATION.md +++ b/SPECIFICATION.md @@ -1,19 +1,36 @@ -# Open Receipt v0.1 +# Open Receipt 0.2 -Formal name: **Receipt Evidence Specification v0.1** +Open Receipt is an open, versioned specification for portable, verifiable +commercial evidence. Version 0.2 adds issuer trust resolution, purpose-bound +key lifecycle, historical verification, and issuance attestations. It remains +backward compatible with Open Receipt 0.1. -Open Receipt defines one portable, signed evidence-event envelope. It does not -standardize Receipt's catalogue, routing, mandates, wallets, payment rails, -remedies, seller contracts, or Receipt Score. +Open Receipt standardizes the evidence envelope and its verification model. It +does not standardize catalogues, routing, mandates, wallets, payment rails, +seller contracts, remedies, or regulatory conclusions. -## Event envelope +## 1. Event envelope -Every event contains `spec_version`, `event_id`, `event_type`, `issuer`, -`issued_at`, `transaction_id`, `quote_id`, `commercial_facts`, `evidence`, -`provenance`, `assurance`, `parent_event_hashes`, `signing_key_id`, and -`signature`. +An Open Receipt 0.2 event is a JSON object with these fields: -The initial event types are: +- `spec_version`: the string `0.2`; +- `event_id`: a stable identifier for this evidence event; +- `event_type`: the commercial event represented; +- `issuer`: the issuer identity, metadata reference, and embedded verification + key; +- `issued_at`: a normalized RFC 3339 timestamp; +- `transaction_id` and `quote_id`: correlation identifiers; +- `commercial_facts`, `evidence`, and `provenance`: event-specific public + evidence; +- `assurance`: the issuer's declared assurance level; +- `parent_event_hashes`: immutable references to parent evidence; +- `signing_key_id`: the evidence key identifier; +- `issuer_metadata_version` and `issuer_metadata_hash`: the exact issuer + metadata snapshot used at issuance; +- `issuance_attestation`: an optional separately signed issuance-log entry; +- `signature`: the evidence signature. + +The initial event types remain: - `quote.issued` - `authorization.granted` @@ -22,54 +39,223 @@ The initial event types are: - `settlement.completed` - `reversal.issued` -The initial assurance values are `delivered` and `validated`. `validated` may -be used only when a bound validator actually ran and passed. `guaranteed` is -reserved for possible future work and is not a v0.1 assurance value. +The JSON Schemas in [`schemas`](./schemas) define the complete structural +requirements. Unknown business fields belong inside the open evidence objects, +not as replacements for protocol fields. -## Canonicalization and signature +## 2. Canonicalization and evidence signature -Remove the top-level `signature` property, canonicalize the remaining JSON with -RFC 8785, and UTF-8 encode it. The signature is a detached compact JWS: +To sign an event, remove the top-level `signature` property, normalize supported +timestamps, canonicalize the remaining JSON using RFC 8785 JSON Canonicalization +Scheme rules, and UTF-8 encode the result. -``` +The signature is a detached compact JWS: + +```text BASE64URL(protected)..BASE64URL(signature) ``` The protected header is canonical JSON containing `alg: EdDSA`, the matching -`kid`, and `typ: open-receipt+jws`. The JWS signing input is the protected -segment, a period, and the base64url-encoded canonical event bytes. The -signature algorithm is Ed25519. - -`parent_event_hashes` contains `sha256:` followed by the lowercase SHA-256 hash -of each complete, signed parent event's canonical JSON. A standalone event can -have a valid signature while reporting a partial chain when its parents are not -present. A bundle permits complete offline chain checking. - -## Issuer metadata and trust - -Issuer metadata is published at `/.well-known/receipt-issuer`. It contains -current and retained historical public keys. An embedded JWK permits offline -mathematical verification, but verifiers must compare it with metadata obtained -through a trusted path before treating the claimed issuer identity as trusted. - -No private key, provider credential, private diagnostic, raw fixture body, or -buyer PII belongs in an Open Receipt. - -## Schemas and implementation - -- Event schema: `/open-receipt/v0.1/event.schema.json` -- Bundle schema: `/open-receipt/v0.1/bundle.schema.json` -- Issuer schema: `/open-receipt/v0.1/issuer.schema.json` -- TypeScript verifier: `packages/open-receipt` -- CLI: `receipt verify ./receipt.json` - -The published vectors include a Delivered v0.1 event, a validation-enabled v0.1 -bundle, and all required tamper cases signed by a non-production test key. The -repository also preserves the live homepage's already-completed signed Receipt -as `production-legacy.valid.json` with its published public key. That artifact's -Ed25519 signature is tested entirely offline. It remains explicitly typed as -`com.receiptprotocol.receipt.v1`; it is a production compatibility/provenance -vector and is not misrepresented as a newly signed Open Receipt v0.1 event. - -Open Receipt v0.1 is an early open specification, not an adopted industry -standard. It is deliberately narrow and does not imply a universal guarantee. +`kid`, and `typ: open-receipt+jws`. The signing input is the protected segment, +a period, and the base64url-encoded canonical event bytes. The signature +algorithm is Ed25519. + +The signing key MUST have the `open_receipt_evidence` purpose and MUST be active +at issuance. A matching embedded public key proves signature consistency; it +does not, by itself, establish a trusted issuer identity. + +## 3. Parent evidence chains + +`parent_event_hashes` links an event to complete, signed parent envelopes. The +canonical reference form is `sha256:` followed by the lowercase SHA-256 digest +of the complete signed parent's canonical JSON. + +Version 0.2.1 also accepts immutable early receipts that encoded the same +64-character lowercase SHA-256 digest without the `sha256:` prefix. New events +SHOULD use the prefixed canonical form. Verifiers MUST NOT rewrite or re-sign a +historical event merely to change its parent reference representation. + +A standalone event can have a valid signature while reporting an incomplete +chain. A bundle supplies parent events for offline chain verification. A +verifier distinguishes signature validity from chain completeness and rejects +a supplied parent whose digest does not match its reference. + +## 4. Issuer metadata references + +Every 0.2 event identifies: + +- the HTTPS issuer origin; +- the issuer metadata URL; +- an exact `issuer_metadata_version`; +- the canonical `issuer_metadata_hash` for that version; +- the evidence signing key ID and embedded public JWK. + +The current issuer snapshot is published at +`/.well-known/receipt-issuer`. Exact historical snapshots are published at +`/.well-known/receipt-issuer/versions/{metadata_version}`. + +Each issuer metadata snapshot contains a monotonically increasing version, +issuer, issue and freshness times, previous-version hash, public-purpose keys, +key lifecycle facts, and a metadata-key signature. Snapshots are immutable and +hash-chained. The metadata signature covers the canonical metadata document, +including its signature key ID and algorithm, while excluding only the +signature value. + +## 5. Purpose-bound keys + +Every registered key has one exact machine-readable purpose: + +| Purpose | Authorized use | Public issuer metadata | +| --- | --- | --- | +| `open_receipt_evidence` | Open Receipt evidence | yes | +| `issuer_metadata` | issuer metadata snapshots | yes | +| `issuance_log` | attestations and checkpoints | yes | +| `commerce_quote` | canonical quotes | no | +| `purchase_approval` | purchase approval records | no | +| `oauth_or_authentication` | authentication only | no | + +A public-key fingerprint MUST NOT be registered for multiple purposes. +Evidence signatures confer no wallet, purchase, allowance, approval, account, +or authentication authority. Cross-purpose signing and verification attempts +fail closed. + +## 6. Key lifecycle + +Issuer metadata records lifecycle state and timestamps: + +- `preactive`: published for preparation and not authorized to sign; +- `active`: authorized to sign for its exact purpose; +- `retired`: no longer authorized to sign; historical signatures issued before + `retired_at` and within the validity window remain eligible; +- `revoked`: signatures at or after `revocation_effective_from` are invalid; + older metadata without a separate effective time uses `revoked_at`; +- `compromised`: signatures at or after `compromise_effective_from` are invalid + or untrusted; when the effective time is unknown, the verifier does not + invent a safe period; +- `destroyed`: private material is unavailable while public verification + metadata remains. + +Keys rotate; existing Receipt signatures do not. A Receipt retains its original +signature and `signing_key_id`, and the issuer retains historical public keys. +The metadata can name a replacement key without changing old evidence. + +## 7. Trust resolution + +`verifyOpenReceiptTrust` supports three trust modes: + +- `embedded_only`: validates signature consistency with the embedded key and + leaves issuer identity untrusted; +- `pinned_metadata`: uses an explicitly trusted signed metadata snapshot and + hash, including controlled offline verification; +- `https_webpki`: resolves the exact version from an allowlisted HTTPS issuer + origin and validates the signed hash chain. + +The HTTPS resolver rejects arbitrary metadata URLs, non-HTTPS origins, +unallowlisted origins, private or local literal hosts, redirects, invalid +content types, oversized responses, rewritten cached versions, and broken +chains. Implementations use bounded timeouts, ETags, and a maximum chain length. + +## 8. Historical verification and freshness + +Verification answers separate questions: + +1. Does the canonical event match its Ed25519 detached JWS? +2. Which issuer does the event claim? +3. Which configured trust path resolved that issuer? +4. Did the resolved metadata authorize the evidence key at issuance? +5. What key lifecycle state applied at issuance? +6. What newer state was known by the resolved metadata snapshot? +7. Is the snapshot fresh enough for the verifier's policy? +8. Does an independent issuance attestation support the issuance time? + +The result reports the metadata snapshot time and freshness separately from +signature validity. An offline verifier cannot discover a revocation or +compromise published after its cached snapshot. Stale metadata therefore does +not invalidate a mathematical signature, but it limits the verifier's claim +about current key state. + +Historical conclusions distinguish active or retired validity at issuance, +signing after retirement, signing before or after revocation, signing before or +after compromise, unknown compromise time, validity-window failures, wrong +purpose, unknown key, stale metadata, and untrusted issuer. + +## 9. Issuance attestations + +An issuance attestation is signed by an independent `issuance_log` key and +binds: + +- issuer; +- append-only sequence; +- Receipt digest; +- evidence signing key ID; +- issuance time; +- previous and current entry hashes; +- log signing key ID and signature. + +The attestation signature and Receipt digest MUST both validate. The log key +MUST be authorized at issuance. A pre-compromise evidence claim needs stronger +evidence against backdating; a valid independent log attestation can provide +that evidence. Without it, the verifier returns an indeterminate conclusion +instead of inventing certainty. + +Public entries contain no transaction input or output, buyer identity, private +commercial payload, provider credential, or private signing material. + +## 10. Issuance-log checkpoints + +An issuer may publish a signed checkpoint at +`/.well-known/receipt-issuer/checkpoint`. A checkpoint commits to the issuer, +latest sequence, latest entry hash, checkpoint time, metadata version, and log +key signature. + +Published entries and checkpoints are immutable. Checkpoints allow a verifier +to compare an attestation with a later trusted commitment to the append-only +log. They strengthen consistency and backdating detection; they do not reveal +the Receipt's private payload and do not replace event or metadata signature +verification. + +## 11. Assurance semantics + +The event-level assurance values remain `delivered` and `validated`. +`validated` means a bound validator ran and passed for that event. It does not +mean permanent certification, universal correctness, regulatory approval, or a +guarantee. + +The trust-aware verifier separately returns: + +- `overall_status`: `valid`, `valid_with_warnings`, `invalid`, or + `indeterminate`; +- mathematical signature validity; +- claimed, resolved, and trusted issuer; +- metadata signature, chain, freshness, and knowledge time; +- key purpose and lifecycle conclusion; +- issuance-attestation status; +- machine-readable warnings and errors. + +Consumers MUST apply their own policy to these separate facts. They MUST NOT +collapse an embedded-key signature into an issuer-identity claim. + +## 12. Open Receipt 0.1 compatibility + +The v0.1 event, bundle, and issuer schemas remain available. The +`verifyOpenReceipt` and `verifyOpenReceiptBundle` APIs retain their established +meaning, and the original deterministic v0.1 vectors remain part of the test +suite. + +Existing v0.1 Receipts are not rewritten or re-signed. A v0.1 Receipt without +an issuance attestation remains cryptographically verifiable with lower trust +assurance. Historical v0.1 documentation and source remain permanently +available through the `v0.1.0` tag. + +## 13. Conformance and security + +Conformance is defined together by this specification, the JSON Schemas, the +deterministic public vectors, and verifier behavior for each supported version. +The v0.2 catalog contains 30 trust and lifecycle cases, including positive, +negative, historical, replay, freshness, and compatibility behavior. + +No private key, provider credential, raw private payload, buyer personal data, +or production secret belongs in an Open Receipt, public issuer document, test +vector, or this repository. Private signing material remains in the issuer's +signing provider; only public keys, fingerprints, purposes, and lifecycle facts +are published. diff --git a/bun.lock b/bun.lock index 7ae6bb6..edde8e4 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,8 @@ "": { "name": "@receiptprotocol/open-receipt", "devDependencies": { - "@types/node": "22.19.17", - "typescript": "5.9.3", + "@types/node": "^22.16.5", + "typescript": "^5.8.3", }, }, }, diff --git a/examples/verify.ts b/examples/verify.ts index 9e9d68b..97389c5 100644 --- a/examples/verify.ts +++ b/examples/verify.ts @@ -1,8 +1,21 @@ import { readFile } from "node:fs/promises"; -import { verifyOpenReceiptBundle } from "@receiptprotocol/open-receipt"; +import { verifyOpenReceiptTrust } from "@receiptprotocol/open-receipt"; -const document = JSON.parse(await readFile(process.argv[2]!, "utf8")); -const result = await verifyOpenReceiptBundle(document); +const receiptPath = process.argv[2]; +const metadataPath = process.argv[3]; +const trustedMetadataHash = process.argv[4]; + +if (!receiptPath || !metadataPath || !trustedMetadataHash) { + throw new Error("usage: verify "); +} + +const receipt = JSON.parse(await readFile(receiptPath, "utf8")); +const pinnedMetadata = JSON.parse(await readFile(metadataPath, "utf8")); +const result = await verifyOpenReceiptTrust(receipt, { + trustMode: "pinned_metadata", + pinnedMetadata, + pinnedMetadataHash: trustedMetadataHash, +}); console.log(JSON.stringify(result, null, 2)); -process.exitCode = result.valid ? 0 : 1; +process.exitCode = result.overall_status === "invalid" ? 1 : 0; diff --git a/package.json b/package.json index e66d40f..a98956f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@receiptprotocol/open-receipt", - "version": "0.1.0", - "description": "Canonicalization and offline verification for Open Receipt v0.1", + "version": "0.2.1", + "description": "Canonicalization, historical verification, and issuer trust for Open Receipt", "license": "MIT", "homepage": "https://github.com/Receiptprotocol/open-receipt#readme", "repository": { @@ -23,7 +23,6 @@ "files": [ "dist", "schemas", - "SPECIFICATION.md", "README.md" ], "scripts": { @@ -38,8 +37,8 @@ "node": ">=20.0.0" }, "devDependencies": { - "@types/node": "22.19.17", - "typescript": "5.9.3" + "@types/node": "^22.16.5", + "typescript": "^5.8.3" }, "publishConfig": { "access": "public" diff --git a/schemas/event-v02.schema.json b/schemas/event-v02.schema.json new file mode 100644 index 0000000..5f9ad6d --- /dev/null +++ b/schemas/event-v02.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://receiptprotocol.com/open-receipt/v0.2/event.schema.json", + "title": "Open Receipt event v0.2", + "type": "object", + "additionalProperties": false, + "required": [ + "spec_version", + "event_id", + "event_type", + "issuer", + "issued_at", + "transaction_id", + "quote_id", + "commercial_facts", + "evidence", + "provenance", + "assurance", + "parent_event_hashes", + "signing_key_id", + "issuer_metadata_version", + "issuer_metadata_hash", + "signature" + ], + "properties": { + "spec_version": { "const": "0.2" }, + "event_id": { "type": "string", "minLength": 1 }, + "event_type": { + "enum": [ + "quote.issued", + "authorization.granted", + "execution.attempted", + "validation.completed", + "settlement.completed", + "reversal.issued" + ] + }, + "issuer": { "$ref": "event.schema.json#/$defs/issuer" }, + "issued_at": { "type": "string", "format": "date-time" }, + "transaction_id": { "type": "string", "minLength": 1 }, + "quote_id": { "type": ["string", "null"] }, + "commercial_facts": { "type": "object", "additionalProperties": true }, + "evidence": { "type": "object", "additionalProperties": true }, + "provenance": { "type": "object", "additionalProperties": true }, + "assurance": { "enum": ["delivered", "validated"] }, + "parent_event_hashes": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?:sha256:)?[a-f0-9]{64}$", + "description": "SHA-256 reference. New events use the sha256: prefix; bare digests remain accepted for immutable early-v0.2 events." + }, + "uniqueItems": true + }, + "signing_key_id": { "type": "string", "minLength": 1 }, + "issuer_metadata_version": { "type": "integer", "minimum": 1 }, + "issuer_metadata_hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "issuance_attestation": { "$ref": "issuance-attestation.schema.json" }, + "signature": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+\\.\\.[A-Za-z0-9_-]+$" + } + } +} diff --git a/schemas/issuance-attestation.schema.json b/schemas/issuance-attestation.schema.json new file mode 100644 index 0000000..5c66a91 --- /dev/null +++ b/schemas/issuance-attestation.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://receiptprotocol.com/open-receipt/v0.2/issuance-attestation.schema.json", + "title": "Open Receipt issuance attestation v1", + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "version", + "issuer", + "sequence", + "receipt_digest", + "evidence_signing_key_id", + "issued_at", + "previous_entry_hash", + "entry_hash", + "log_signing_key_id", + "signature" + ], + "properties": { + "type": { "const": "open_receipt_issuance_attestation" }, + "version": { "const": 1 }, + "issuer": { "type": "string", "format": "uri", "pattern": "^https://" }, + "sequence": { "type": "integer", "minimum": 1 }, + "receipt_digest": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "evidence_signing_key_id": { "type": "string", "minLength": 1 }, + "issued_at": { "type": "string", "format": "date-time" }, + "previous_entry_hash": { + "oneOf": [{ "type": "null" }, { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }] + }, + "entry_hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }, + "log_signing_key_id": { "type": "string", "minLength": 1 }, + "signature": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" } + } +} diff --git a/schemas/issuer-metadata-v02.schema.json b/schemas/issuer-metadata-v02.schema.json new file mode 100644 index 0000000..2296416 --- /dev/null +++ b/schemas/issuer-metadata-v02.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://receiptprotocol.com/open-receipt/v0.2/issuer-metadata.schema.json", + "title": "Open Receipt issuer metadata v0.2", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "issuer", + "metadata_version", + "issued_at", + "next_update", + "previous_metadata_hash", + "keys", + "signature" + ], + "properties": { + "schema": { + "const": "https://receiptprotocol.com/open-receipt/v0.2/issuer-metadata.schema.json" + }, + "issuer": { "type": "string", "format": "uri", "pattern": "^https://" }, + "metadata_version": { "type": "integer", "minimum": 1 }, + "issued_at": { "type": "string", "format": "date-time" }, + "next_update": { "type": "string", "format": "date-time" }, + "previous_metadata_hash": { + "oneOf": [{ "type": "null" }, { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }] + }, + "keys": { + "type": "array", + "items": { "$ref": "#/$defs/key" } + }, + "signature": { "$ref": "#/$defs/signature" } + }, + "$defs": { + "publicJwk": { + "type": "object", + "additionalProperties": false, + "required": ["kty", "crv", "x", "kid"], + "properties": { + "kty": { "const": "OKP" }, + "crv": { "const": "Ed25519" }, + "x": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" }, + "kid": { "type": "string", "minLength": 1 }, + "use": { "const": "sig" }, + "alg": { "const": "EdDSA" } + } + }, + "key": { + "type": "object", + "additionalProperties": false, + "required": [ + "kid", + "purpose", + "alg", + "public_jwk", + "status", + "valid_from", + "valid_until", + "activated_at", + "retired_at", + "revoked_at", + "revocation_effective_from", + "compromised_at", + "compromise_effective_from", + "destroyed_at", + "replaced_by_kid", + "reason_code" + ], + "properties": { + "kid": { "type": "string", "minLength": 1 }, + "purpose": { + "enum": ["open_receipt_evidence", "issuer_metadata", "issuance_log"] + }, + "alg": { "const": "EdDSA" }, + "public_jwk": { "$ref": "#/$defs/publicJwk" }, + "status": { + "enum": ["preactive", "active", "retired", "revoked", "compromised", "destroyed"] + }, + "valid_from": { "type": "string", "format": "date-time" }, + "valid_until": { "type": ["string", "null"], "format": "date-time" }, + "activated_at": { "type": ["string", "null"], "format": "date-time" }, + "retired_at": { "type": ["string", "null"], "format": "date-time" }, + "revoked_at": { "type": ["string", "null"], "format": "date-time" }, + "revocation_effective_from": { "type": ["string", "null"], "format": "date-time" }, + "compromised_at": { "type": ["string", "null"], "format": "date-time" }, + "compromise_effective_from": { "type": ["string", "null"], "format": "date-time" }, + "destroyed_at": { "type": ["string", "null"], "format": "date-time" }, + "replaced_by_kid": { "type": ["string", "null"] }, + "replacement_kid": { + "type": ["string", "null"], + "description": "Accepted alias for replaced_by_kid; canonical issuers emit replaced_by_kid." + }, + "reason_code": { "type": ["string", "null"] } + } + }, + "signature": { + "type": "object", + "additionalProperties": false, + "required": ["kid", "alg", "value"], + "properties": { + "kid": { "type": "string", "minLength": 1 }, + "alg": { "const": "EdDSA" }, + "value": { "type": "string", "pattern": "^[A-Za-z0-9_-]+$" } + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 97f9244..6d06243 100644 --- a/src/index.ts +++ b/src/index.ts @@ -467,3 +467,5 @@ export async function signOpenReceipt( ); return { ...unsigned, signature: `${protectedSegment}..${bytesToBase64Url(signature)}` }; } + +export * from "./v02.js"; diff --git a/src/v02.ts b/src/v02.ts new file mode 100644 index 0000000..b4d9727 --- /dev/null +++ b/src/v02.ts @@ -0,0 +1,1523 @@ +import { + canonicalize, + sha256Hex, + type Ed25519PublicJwk, + type OpenReceiptAssurance, + type OpenReceiptEventType, + type OpenReceiptIssuer, +} from "./index.js"; + +export const OPEN_RECEIPT_V02_SPEC_VERSION = "0.2" as const; +export const OPEN_RECEIPT_ISSUER_METADATA_SCHEMA = + "https://receiptprotocol.com/open-receipt/v0.2/issuer-metadata.schema.json" as const; + +export const OPEN_RECEIPT_KEY_PURPOSES = [ + "open_receipt_evidence", + "issuer_metadata", + "issuance_log", + "commerce_quote", + "purchase_approval", + "oauth_or_authentication", +] as const; + +export const OPEN_RECEIPT_PUBLIC_KEY_PURPOSES = [ + "open_receipt_evidence", + "issuer_metadata", + "issuance_log", +] as const; + +export const OPEN_RECEIPT_KEY_STATUSES = [ + "preactive", + "active", + "retired", + "revoked", + "compromised", + "destroyed", +] as const; + +export type OpenReceiptKeyPurpose = (typeof OPEN_RECEIPT_KEY_PURPOSES)[number]; +export type OpenReceiptPublicKeyPurpose = (typeof OPEN_RECEIPT_PUBLIC_KEY_PURPOSES)[number]; +export type OpenReceiptKeyStatus = (typeof OPEN_RECEIPT_KEY_STATUSES)[number]; +export type OpenReceiptTrustMode = "embedded_only" | "pinned_metadata" | "https_webpki"; +export type MetadataFreshness = "fresh" | "stale" | "unknown"; + +export type IssuerMetadataKeyV02 = { + kid: string; + purpose: OpenReceiptPublicKeyPurpose; + alg: "EdDSA"; + public_jwk: Ed25519PublicJwk; + status: OpenReceiptKeyStatus; + valid_from: string; + valid_until: string | null; + activated_at: string | null; + retired_at: string | null; + revoked_at: string | null; + revocation_effective_from: string | null; + compromised_at: string | null; + compromise_effective_from: string | null; + destroyed_at: string | null; + replaced_by_kid: string | null; + replacement_kid?: string | null; + reason_code: string | null; +}; + +export type IssuerMetadataSignature = { + kid: string; + alg: "EdDSA"; + value: string; +}; + +export type UnsignedIssuerMetadataV02 = { + schema: typeof OPEN_RECEIPT_ISSUER_METADATA_SCHEMA; + issuer: string; + metadata_version: number; + issued_at: string; + next_update: string; + previous_metadata_hash: string | null; + keys: IssuerMetadataKeyV02[]; +}; + +export type IssuerMetadataV02 = UnsignedIssuerMetadataV02 & { + signature: IssuerMetadataSignature; +}; + +export type IssuanceAttestation = { + type: "open_receipt_issuance_attestation"; + version: 1; + issuer: string; + sequence: number; + receipt_digest: string; + evidence_signing_key_id: string; + issued_at: string; + previous_entry_hash: string | null; + entry_hash: string; + log_signing_key_id: string; + signature: string; +}; + +export type IssuanceLogCheckpoint = { + type: "open_receipt_issuance_log_checkpoint"; + version: 1; + issuer: string; + latest_sequence: number; + latest_entry_hash: string; + issued_at: string; + signing_key_id: string; + signature: string; +}; + +export type OpenReceiptEventV02 = { + spec_version: "0.2"; + event_id: string; + event_type: OpenReceiptEventType; + issuer: OpenReceiptIssuer; + issued_at: string; + transaction_id: string; + quote_id: string | null; + commercial_facts: Record; + evidence: Record; + provenance: Record; + assurance: OpenReceiptAssurance; + parent_event_hashes: string[]; + signing_key_id: string; + issuer_metadata_version: number; + issuer_metadata_hash: string; + issuance_attestation?: IssuanceAttestation; + signature: string; +}; + +export type IssuerResolution = { + metadata: IssuerMetadataV02; + trust_mode: Exclude; + resolved_at: string; + trusted: boolean; + etag?: string; + previous_metadata?: IssuerMetadataV02[]; +}; + +export interface IssuerResolver { + readonly trustMode: Exclude; + resolve(input: { + issuer: string; + metadataVersion: number; + metadataHash: string; + }): Promise; +} + +export type HistoricalKeyResult = + | "active_key_valid_at_issuance" + | "retired_key_valid_at_issuance" + | "signed_after_retirement" + | "signed_before_revocation" + | "signed_after_revocation" + | "signed_before_compromise" + | "signed_after_compromise" + | "compromise_time_unknown" + | "key_not_yet_valid" + | "key_expired" + | "key_purpose_mismatch" + | "key_unknown" + | "key_status_unknown"; + +export type HistoricalConclusion = + | "valid_at_issuance" + | "valid_but_metadata_stale" + | "retired_key_valid_at_issuance" + | "signed_after_revocation" + | "signed_after_compromise" + | "key_status_unknown"; + +export type TrustAwareVerificationResult = { + overall_status: "valid" | "valid_with_warnings" | "invalid" | "indeterminate"; + signature: { + valid: boolean; + algorithm: "EdDSA" | null; + kid: string | null; + }; + issuer: { + claimed: string | null; + resolved: boolean; + trusted: boolean; + trust_mode: OpenReceiptTrustMode; + }; + metadata: { + version: number | null; + hash_valid: boolean | null; + signature_valid: boolean | null; + chain_valid: boolean | null; + issued_at: string | null; + next_update: string | null; + resolved_at: string | null; + age_seconds: number | null; + freshness: MetadataFreshness; + as_of: string | null; + key_valid_as_of_metadata: boolean | null; + }; + key: { + purpose: OpenReceiptKeyPurpose | null; + status_at_issuance: "active" | "inactive" | "unknown"; + current_status: OpenReceiptKeyStatus | "unknown"; + historical_result: HistoricalKeyResult; + conclusion: HistoricalConclusion; + }; + issuance_attestation: { + status: "valid" | "invalid" | "missing"; + sequence: number | null; + }; + metadata_as_of: string | null; + metadata_freshness: MetadataFreshness; + key_valid_as_of_metadata: boolean | null; + warnings: string[]; + errors: string[]; +}; + +type DetachedHeader = { + alg: "EdDSA"; + kid: string; + typ: "open-receipt+jws"; +}; + +const encoder = new TextEncoder(); +const PUBLIC_PURPOSES = new Set(OPEN_RECEIPT_PUBLIC_KEY_PURPOSES); + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]*$/.test(value)) throw new TypeError("malformed_base64url"); + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4)); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +function base64UrlToText(value: string): string { + return new TextDecoder().decode(base64UrlToBytes(value)); +} + +function timestamp(value: string, field: string): string { + if ( + typeof value !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) + ) { + throw new TypeError(`invalid_timestamp:${field}`); + } + const milliseconds = Date.parse(value); + if (!Number.isFinite(milliseconds)) throw new TypeError(`invalid_timestamp:${field}`); + return new Date(milliseconds).toISOString(); +} + +function optionalTimestamp(value: string | null, field: string): string | null { + return value === null ? null : timestamp(value, field); +} + +export function canonicalizeIssuerMetadata( + metadata: UnsignedIssuerMetadataV02, +): UnsignedIssuerMetadataV02 { + const metadataFields = new Set([ + "schema", + "issuer", + "metadata_version", + "issued_at", + "next_update", + "previous_metadata_hash", + "keys", + ]); + for (const field of Object.keys(metadata)) { + if (!metadataFields.has(field)) throw new TypeError(`unknown_metadata_field:${field}`); + } + if (metadata.schema !== OPEN_RECEIPT_ISSUER_METADATA_SCHEMA) { + throw new TypeError("unsupported_metadata_schema"); + } + if (!Array.isArray(metadata.keys)) throw new TypeError("invalid_metadata_keys"); + if (!Number.isSafeInteger(metadata.metadata_version) || metadata.metadata_version < 1) { + throw new TypeError("invalid_metadata_version"); + } + const issuer = new URL(metadata.issuer); + if (issuer.protocol !== "https:" || issuer.origin !== metadata.issuer.replace(/\/$/, "")) { + throw new TypeError("invalid_issuer_origin"); + } + if ( + !/^sha256:[a-f0-9]{64}$/.test(metadata.previous_metadata_hash ?? "sha256:" + "0".repeat(64)) + ) { + throw new TypeError("invalid_previous_metadata_hash"); + } + const issuedAt = timestamp(metadata.issued_at, "issued_at"); + const nextUpdate = timestamp(metadata.next_update, "next_update"); + if (Date.parse(nextUpdate) <= Date.parse(issuedAt)) throw new TypeError("invalid_next_update"); + const seen = new Set(); + const keys = metadata.keys.map((key) => { + const keyFields = new Set([ + "kid", + "purpose", + "alg", + "public_jwk", + "status", + "valid_from", + "valid_until", + "activated_at", + "retired_at", + "revoked_at", + "revocation_effective_from", + "compromised_at", + "compromise_effective_from", + "destroyed_at", + "replaced_by_kid", + "replacement_kid", + "reason_code", + ]); + for (const field of Object.keys(key)) { + if (!keyFields.has(field)) throw new TypeError(`unknown_metadata_key_field:${field}`); + } + const jwkFields = new Set(["kty", "crv", "x", "kid", "use", "alg"]); + for (const field of Object.keys(key.public_jwk ?? {})) { + if (!jwkFields.has(field)) throw new TypeError(`unknown_public_jwk_field:${field}`); + } + if (seen.has(key.kid)) throw new TypeError("duplicate_metadata_kid"); + seen.add(key.kid); + if (!PUBLIC_PURPOSES.has(key.purpose)) throw new TypeError("non_public_key_purpose"); + if ( + key.alg !== "EdDSA" || + key.public_jwk.kty !== "OKP" || + key.public_jwk.crv !== "Ed25519" || + key.public_jwk.alg !== "EdDSA" || + key.public_jwk.kid !== key.kid || + "d" in key.public_jwk + ) { + throw new TypeError("unsupported_metadata_key"); + } + if (!/^[A-Za-z0-9_-]{43}$/.test(key.public_jwk.x)) { + throw new TypeError("invalid_metadata_public_key"); + } + if ( + key.replacement_kid !== undefined && + key.replaced_by_kid !== null && + key.replacement_kid !== key.replaced_by_kid + ) { + throw new TypeError("conflicting_replacement_kid"); + } + const validFrom = timestamp(key.valid_from, `keys.${key.kid}.valid_from`); + const validUntil = optionalTimestamp(key.valid_until, `keys.${key.kid}.valid_until`); + const retiredAt = optionalTimestamp(key.retired_at, `keys.${key.kid}.retired_at`); + const revokedAt = optionalTimestamp(key.revoked_at, `keys.${key.kid}.revoked_at`); + const revocationEffectiveFrom = optionalTimestamp( + key.revocation_effective_from, + `keys.${key.kid}.revocation_effective_from`, + ); + const compromisedAt = optionalTimestamp(key.compromised_at, `keys.${key.kid}.compromised_at`); + const compromiseEffectiveFrom = optionalTimestamp( + key.compromise_effective_from, + `keys.${key.kid}.compromise_effective_from`, + ); + if (validUntil && Date.parse(validUntil) < Date.parse(validFrom)) { + throw new TypeError("invalid_key_validity_window"); + } + if (key.status === "retired" && !retiredAt) throw new TypeError("retired_at_required"); + if (key.status === "revoked" && !revokedAt) throw new TypeError("revoked_at_required"); + if (key.status === "compromised" && !compromisedAt) { + throw new TypeError("compromised_at_required"); + } + const destroyedAt = optionalTimestamp(key.destroyed_at, `keys.${key.kid}.destroyed_at`); + if (key.status === "destroyed" && !destroyedAt) { + throw new TypeError("destroyed_at_required"); + } + if (compromiseEffectiveFrom && !compromisedAt) { + throw new TypeError("compromised_at_required"); + } + const { replacement_kid: _replacementKidAlias, ...keyWithoutAlias } = key; + void _replacementKidAlias; + return { + ...keyWithoutAlias, + valid_from: validFrom, + valid_until: validUntil, + activated_at: optionalTimestamp(key.activated_at, `keys.${key.kid}.activated_at`), + retired_at: retiredAt, + revoked_at: revokedAt, + revocation_effective_from: revocationEffectiveFrom, + compromised_at: compromisedAt, + compromise_effective_from: compromiseEffectiveFrom, + destroyed_at: destroyedAt, + replaced_by_kid: key.replaced_by_kid ?? key.replacement_kid ?? null, + reason_code: key.reason_code ?? null, + }; + }); + return { + ...metadata, + issuer: issuer.origin, + issued_at: issuedAt, + next_update: nextUpdate, + keys, + }; +} + +async function importVerificationKey(key: Ed25519PublicJwk): Promise { + return crypto.subtle.importKey("jwk", key, { name: "Ed25519" }, false, ["verify"]); +} + +async function importSigningKey(key: CryptoKey | JsonWebKey): Promise { + return "type" in key + ? key + : crypto.subtle.importKey("jwk", key, { name: "Ed25519" }, false, ["sign"]); +} + +async function verifyEd25519( + key: Ed25519PublicJwk, + signature: string, + canonicalPayload: string, +): Promise { + try { + return await crypto.subtle.verify( + { name: "Ed25519" }, + await importVerificationKey(key), + base64UrlToBytes(signature) as BufferSource, + encoder.encode(canonicalPayload) as BufferSource, + ); + } catch { + return false; + } +} + +export async function signIssuerMetadata( + metadata: UnsignedIssuerMetadataV02, + privateKey: CryptoKey | JsonWebKey, + metadataSigningKid: string, +): Promise { + return signIssuerMetadataWith( + metadata, + metadataSigningKid, + async (payload) => + new Uint8Array( + await crypto.subtle.sign( + { name: "Ed25519" }, + await importSigningKey(privateKey), + payload as BufferSource, + ), + ), + ); +} + +export async function signIssuerMetadataWith( + metadata: UnsignedIssuerMetadataV02, + metadataSigningKid: string, + sign: (canonicalPayload: Uint8Array) => Promise, +): Promise { + const canonical = canonicalizeIssuerMetadata(metadata); + const key = canonical.keys.find( + (candidate) => + candidate.kid === metadataSigningKid && + candidate.purpose === "issuer_metadata" && + candidate.status === "active", + ); + if (!key) throw new Error("active_metadata_signing_key_missing"); + const signatureHeader = { kid: metadataSigningKid, alg: "EdDSA" as const }; + const signature = await sign( + encoder.encode(canonicalize({ ...canonical, signature: signatureHeader })), + ); + return { + ...canonical, + signature: { + ...signatureHeader, + value: bytesToBase64Url(signature), + }, + }; +} + +export async function issuerMetadataHash(metadata: IssuerMetadataV02): Promise { + return `sha256:${await sha256Hex(metadata)}`; +} + +export async function verifyIssuerMetadata( + metadata: IssuerMetadataV02, + previous?: IssuerMetadataV02, +): Promise<{ + valid: boolean; + signature_valid: boolean; + hash: string | null; + chain_valid: boolean; + errors: string[]; +}> { + const errors: string[] = []; + if ( + !isObject(metadata.signature) || + Object.keys(metadata.signature).some((field) => !["kid", "alg", "value"].includes(field)) + ) { + return { + valid: false, + signature_valid: false, + hash: null, + chain_valid: false, + errors: ["invalid_metadata_signature"], + }; + } + let canonical: UnsignedIssuerMetadataV02; + try { + const { signature: _signature, ...unsigned } = metadata; + void _signature; + canonical = canonicalizeIssuerMetadata(unsigned); + } catch (error) { + return { + valid: false, + signature_valid: false, + hash: null, + chain_valid: false, + errors: [error instanceof Error ? error.message : "invalid_metadata"], + }; + } + const signingKey = canonical.keys.find( + (key) => key.kid === metadata.signature?.kid && key.purpose === "issuer_metadata", + ); + if (!signingKey) errors.push("metadata_signing_key_missing_or_wrong_purpose"); + if (signingKey) { + const signingKeyState = evaluateHistoricalKey(signingKey, Date.parse(canonical.issued_at)); + if (!signingKeyState.valid) errors.push("metadata_signing_key_invalid_at_issuance"); + } + if (metadata.signature?.alg !== "EdDSA") errors.push("unsupported_metadata_signature_algorithm"); + const signatureValid = Boolean( + signingKey && + (await verifyEd25519( + signingKey.public_jwk, + metadata.signature.value, + canonicalize({ + ...canonical, + signature: { kid: metadata.signature.kid, alg: metadata.signature.alg }, + }), + )), + ); + if (!signatureValid) errors.push("metadata_signature_invalid"); + let chainValid = metadata.metadata_version === 1 && metadata.previous_metadata_hash === null; + if (previous) { + const previousHash = await issuerMetadataHash(previous); + chainValid = + metadata.metadata_version === previous.metadata_version + 1 && + metadata.previous_metadata_hash === previousHash && + metadata.issuer === previous.issuer; + } + if (!chainValid) errors.push("metadata_hash_chain_invalid"); + return { + valid: signatureValid && chainValid && errors.length === 0, + signature_valid: signatureValid, + hash: await issuerMetadataHash(metadata), + chain_valid: chainValid, + errors, + }; +} + +export async function verifyIssuerMetadataChain( + versions: IssuerMetadataV02[], +): Promise<{ valid: boolean; errors: string[] }> { + const ordered = [...versions].sort((a, b) => a.metadata_version - b.metadata_version); + const errors: string[] = []; + for (let index = 0; index < ordered.length; index += 1) { + const result = await verifyIssuerMetadata(ordered[index]!, ordered[index - 1]); + if (!result.valid) { + errors.push( + ...result.errors.map( + (error) => `metadata_version_${ordered[index]!.metadata_version}:${error}`, + ), + ); + } + } + return { valid: errors.length === 0, errors }; +} + +function unsignedAttestation( + attestation: IssuanceAttestation, +): Omit { + const { signature: _signature, ...unsigned } = attestation; + void _signature; + return unsigned; +} + +function attestationEntryBody( + attestation: Omit, +): Omit { + const { entry_hash: _entryHash, ...body } = attestation; + void _entryHash; + return body; +} + +export async function issuanceEntryHash( + attestation: Omit, +): Promise { + const hashInput = [ + "open-receipt-issuance-v1", + attestation.type, + String(attestation.version), + attestation.issuer, + String(attestation.sequence), + attestation.receipt_digest, + attestation.evidence_signing_key_id, + timestamp(attestation.issued_at, "attestation.issued_at"), + attestation.previous_entry_hash ?? "", + attestation.log_signing_key_id, + ].join("\n"); + return `sha256:${await sha256Hex(hashInput)}`; +} + +export async function signIssuanceAttestation( + input: Omit, + privateKey: CryptoKey | JsonWebKey, +): Promise { + return signIssuanceAttestationWith( + input, + async (payload) => + new Uint8Array( + await crypto.subtle.sign( + { name: "Ed25519" }, + await importSigningKey(privateKey), + payload as BufferSource, + ), + ), + ); +} + +export async function signIssuanceAttestationWith( + input: Omit, + sign: (canonicalPayload: Uint8Array) => Promise, +): Promise { + if (!Number.isSafeInteger(input.sequence) || input.sequence < 1) { + throw new TypeError("invalid_issuance_sequence"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(input.receipt_digest)) { + throw new TypeError("invalid_receipt_digest"); + } + const canonicalInput = { + ...input, + issued_at: timestamp(input.issued_at, "attestation.issued_at"), + }; + const entry_hash = await issuanceEntryHash(canonicalInput); + const unsigned = { ...canonicalInput, entry_hash }; + const signature = await sign(encoder.encode(canonicalize(unsigned))); + return { ...unsigned, signature: bytesToBase64Url(signature) }; +} + +export async function receiptDigestForAttestation(event: OpenReceiptEventV02): Promise { + const { signature: _signature, issuance_attestation: _attestation, ...core } = event; + void _signature; + void _attestation; + return `sha256:${await sha256Hex({ + ...core, + issued_at: timestamp(core.issued_at, "issued_at"), + })}`; +} + +export async function verifyIssuanceAttestation( + event: OpenReceiptEventV02, + metadata: IssuerMetadataV02, +): Promise<{ status: "valid" | "invalid" | "missing"; sequence: number | null; errors: string[] }> { + const attestation = event.issuance_attestation; + if (!attestation) return { status: "missing", sequence: null, errors: [] }; + const errors: string[] = []; + let attestationIssuedAt: string | null = null; + try { + attestationIssuedAt = timestamp(attestation.issued_at, "attestation.issued_at"); + } catch { + errors.push("attestation_issuance_time_invalid"); + } + if (attestation.issuer !== event.issuer.id) errors.push("attestation_issuer_mismatch"); + if (attestationIssuedAt !== null && attestationIssuedAt !== event.issued_at) { + errors.push("attestation_issuance_time_mismatch"); + } + if (!Number.isSafeInteger(attestation.sequence) || attestation.sequence < 1) { + errors.push("attestation_sequence_invalid"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(attestation.receipt_digest)) { + errors.push("attestation_receipt_digest_invalid"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(attestation.entry_hash)) { + errors.push("attestation_entry_hash_invalid"); + } + if ( + attestation.previous_entry_hash !== null && + !/^sha256:[a-f0-9]{64}$/.test(attestation.previous_entry_hash) + ) { + errors.push("attestation_previous_entry_hash_invalid"); + } + if (attestation.evidence_signing_key_id !== event.signing_key_id) { + errors.push("attestation_evidence_key_mismatch"); + } + if (attestation.receipt_digest !== (await receiptDigestForAttestation(event))) { + errors.push("attestation_receipt_digest_mismatch"); + } + try { + if ( + (await issuanceEntryHash(attestationEntryBody(unsignedAttestation(attestation)))) !== + attestation.entry_hash + ) { + errors.push("attestation_entry_hash_invalid"); + } + } catch { + if (!errors.includes("attestation_entry_hash_invalid")) { + errors.push("attestation_entry_hash_invalid"); + } + } + const logKey = metadata.keys.find( + (key) => key.kid === attestation.log_signing_key_id && key.purpose === "issuance_log", + ); + if (!logKey) errors.push("attestation_log_key_missing_or_wrong_purpose"); + if (logKey && attestationIssuedAt) { + const logKeyState = evaluateHistoricalKey(logKey, Date.parse(attestationIssuedAt)); + if (!logKeyState.valid || logKeyState.needsAttestation) { + errors.push("attestation_log_key_not_authorized_at_issuance"); + } + } + if ( + logKey && + !(await verifyEd25519( + logKey.public_jwk, + attestation.signature, + canonicalize(unsignedAttestation(attestation)), + )) + ) { + errors.push("attestation_signature_invalid"); + } + return { + status: errors.length === 0 ? "valid" : "invalid", + sequence: attestation.sequence, + errors, + }; +} + +export async function signIssuanceLogCheckpointWith( + input: Omit, + sign: (canonicalPayload: Uint8Array) => Promise, +): Promise { + if (!Number.isSafeInteger(input.latest_sequence) || input.latest_sequence < 1) { + throw new TypeError("invalid_checkpoint_sequence"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(input.latest_entry_hash)) { + throw new TypeError("invalid_checkpoint_entry_hash"); + } + const normalized = { + ...input, + issued_at: timestamp(input.issued_at, "checkpoint.issued_at"), + }; + return { + ...normalized, + signature: bytesToBase64Url(await sign(encoder.encode(canonicalize(normalized)))), + }; +} + +export async function verifyIssuanceLogCheckpoint( + checkpoint: IssuanceLogCheckpoint, + metadata: IssuerMetadataV02, +): Promise { + if ( + checkpoint.type !== "open_receipt_issuance_log_checkpoint" || + checkpoint.version !== 1 || + checkpoint.issuer !== metadata.issuer || + !Number.isSafeInteger(checkpoint.latest_sequence) || + checkpoint.latest_sequence < 1 || + !/^sha256:[a-f0-9]{64}$/.test(checkpoint.latest_entry_hash) + ) { + return false; + } + const key = metadata.keys.find( + (candidate) => + candidate.kid === checkpoint.signing_key_id && candidate.purpose === "issuance_log", + ); + if (!key) return false; + let issuedAt: string; + try { + issuedAt = timestamp(checkpoint.issued_at, "checkpoint.issued_at"); + } catch { + return false; + } + const keyState = evaluateHistoricalKey(key, Date.parse(issuedAt)); + if (!keyState.valid || keyState.needsAttestation) return false; + const { signature: _signature, ...unsigned } = checkpoint; + void _signature; + return verifyEd25519( + key.public_jwk, + checkpoint.signature, + canonicalize({ ...unsigned, issued_at: issuedAt }), + ); +} + +function parseDetachedJws(value: string): { + protectedSegment: string; + signature: Uint8Array; + header: DetachedHeader; +} { + const segments = value.split("."); + if (segments.length !== 3 || segments[1] !== "" || !segments[0] || !segments[2]) { + throw new TypeError("malformed_detached_jws"); + } + const header = JSON.parse(base64UrlToText(segments[0])) as Partial; + if (header.alg !== "EdDSA") throw new TypeError("unsupported_jws_algorithm"); + if (header.typ !== "open-receipt+jws") throw new TypeError("invalid_jws_type"); + if (typeof header.kid !== "string" || !header.kid) throw new TypeError("missing_jws_key_id"); + return { + protectedSegment: segments[0], + signature: base64UrlToBytes(segments[2]), + header: header as DetachedHeader, + }; +} + +function unsignedV02(event: OpenReceiptEventV02): Omit { + const { signature: _signature, ...unsigned } = event; + void _signature; + return unsigned; +} + +function validateV02Event(value: unknown): string[] { + if (!isObject(value)) return ["event_must_be_object"]; + const errors: string[] = []; + const allowed = new Set([ + "spec_version", + "event_id", + "event_type", + "issuer", + "issued_at", + "transaction_id", + "quote_id", + "commercial_facts", + "evidence", + "provenance", + "assurance", + "parent_event_hashes", + "signing_key_id", + "issuer_metadata_version", + "issuer_metadata_hash", + "issuance_attestation", + "signature", + ]); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) errors.push(`unknown_event_field:${field}`); + } + if (value.spec_version !== "0.2") errors.push("unsupported_spec_version"); + if (typeof value.event_id !== "string" || !value.event_id) errors.push("invalid_event_id"); + if ( + typeof value.event_type !== "string" || + ![ + "quote.issued", + "authorization.granted", + "execution.attempted", + "validation.completed", + "settlement.completed", + "reversal.issued", + ].includes(value.event_type) + ) { + errors.push("invalid_event_type"); + } + if ( + !isObject(value.issuer) || + typeof value.issuer.id !== "string" || + typeof value.issuer.metadata_url !== "string" + ) { + errors.push("invalid_issuer"); + } + try { + timestamp(String(value.issued_at), "issued_at"); + } catch { + errors.push("invalid_issued_at"); + } + if ( + !Number.isSafeInteger(value.issuer_metadata_version) || + Number(value.issuer_metadata_version) < 1 + ) { + errors.push("invalid_issuer_metadata_version"); + } + if (!/^sha256:[a-f0-9]{64}$/.test(String(value.issuer_metadata_hash))) { + errors.push("invalid_issuer_metadata_hash"); + } + if (typeof value.signing_key_id !== "string" || !value.signing_key_id) { + errors.push("invalid_signing_key_id"); + } + if (typeof value.signature !== "string" || !value.signature) errors.push("invalid_signature"); + for (const field of ["commercial_facts", "evidence", "provenance"]) { + if (!isObject(value[field])) errors.push(`invalid_${field}`); + } + if (typeof value.transaction_id !== "string" || !value.transaction_id) { + errors.push("invalid_transaction_id"); + } + if (value.quote_id !== null && typeof value.quote_id !== "string") + errors.push("invalid_quote_id"); + if (!["delivered", "validated"].includes(String(value.assurance))) { + errors.push("invalid_assurance"); + } + if ( + !Array.isArray(value.parent_event_hashes) || + value.parent_event_hashes.some( + // Early Receipt v0.2 evidence-chain artifacts used the same SHA-256 + // digest bytes without the explicit algorithm prefix. Accept those + // immutable historical signatures; new issuers must emit `sha256:`. + (hash) => typeof hash !== "string" || !/^(?:sha256:)?[a-f0-9]{64}$/.test(hash), + ) + ) { + errors.push("invalid_parent_event_hashes"); + } + if (value.issuance_attestation !== undefined) { + const attestation = value.issuance_attestation; + if ( + !isObject(attestation) || + attestation.type !== "open_receipt_issuance_attestation" || + attestation.version !== 1 || + !Number.isSafeInteger(attestation.sequence) || + typeof attestation.signature !== "string" + ) { + errors.push("invalid_issuance_attestation"); + } + } + return errors; +} + +export async function signOpenReceiptV02( + unsigned: Omit, + privateKey: CryptoKey | JsonWebKey, + purpose: OpenReceiptKeyPurpose = "open_receipt_evidence", +): Promise { + return signOpenReceiptV02With( + unsigned, + async (payload) => + new Uint8Array( + await crypto.subtle.sign( + { name: "Ed25519" }, + await importSigningKey(privateKey), + payload as BufferSource, + ), + ), + purpose, + ); +} + +export async function signOpenReceiptV02With( + unsigned: Omit, + sign: (signingInput: Uint8Array) => Promise, + purpose: OpenReceiptKeyPurpose = "open_receipt_evidence", +): Promise { + if (purpose !== "open_receipt_evidence") throw new Error("key_purpose_mismatch"); + const normalized = { ...unsigned, issued_at: timestamp(unsigned.issued_at, "issued_at") }; + const header: DetachedHeader = { + alg: "EdDSA", + kid: normalized.signing_key_id, + typ: "open-receipt+jws", + }; + const protectedSegment = bytesToBase64Url(encoder.encode(canonicalize(header))); + const payloadSegment = bytesToBase64Url(encoder.encode(canonicalize(normalized))); + const signature = await sign(encoder.encode(`${protectedSegment}.${payloadSegment}`)); + return { ...normalized, signature: `${protectedSegment}..${bytesToBase64Url(signature)}` }; +} + +type KeyEvaluation = { + valid: boolean; + statusAtIssuance: "active" | "inactive" | "unknown"; + result: HistoricalKeyResult; + conclusion: HistoricalConclusion; + needsAttestation: boolean; +}; + +function evaluateHistoricalKey(key: IssuerMetadataKeyV02, issuedAt: number): KeyEvaluation { + const validFrom = Date.parse(key.valid_from); + const validUntil = key.valid_until ? Date.parse(key.valid_until) : Number.POSITIVE_INFINITY; + if (issuedAt < validFrom) { + return { + valid: false, + statusAtIssuance: "inactive", + result: "key_not_yet_valid", + conclusion: "key_status_unknown", + needsAttestation: false, + }; + } + if (key.revoked_at) { + const revocationEffectiveFrom = key.revocation_effective_from ?? key.revoked_at; + if (issuedAt >= Date.parse(revocationEffectiveFrom)) { + return { + valid: false, + statusAtIssuance: "inactive", + result: "signed_after_revocation", + conclusion: "signed_after_revocation", + needsAttestation: false, + }; + } + return { + valid: true, + statusAtIssuance: "active", + result: "signed_before_revocation", + conclusion: "valid_at_issuance", + needsAttestation: false, + }; + } + if (key.compromised_at) { + if (!key.compromise_effective_from) { + return { + valid: false, + statusAtIssuance: "unknown", + result: "compromise_time_unknown", + conclusion: "key_status_unknown", + needsAttestation: true, + }; + } + if (issuedAt >= Date.parse(key.compromise_effective_from)) { + return { + valid: false, + statusAtIssuance: "inactive", + result: "signed_after_compromise", + conclusion: "signed_after_compromise", + needsAttestation: false, + }; + } + return { + valid: true, + statusAtIssuance: "active", + result: "signed_before_compromise", + conclusion: "valid_at_issuance", + needsAttestation: true, + }; + } + if (key.retired_at || key.status === "retired" || key.status === "destroyed") { + if (!key.retired_at) { + return { + valid: false, + statusAtIssuance: "unknown", + result: "key_status_unknown", + conclusion: "key_status_unknown", + needsAttestation: false, + }; + } + if (key.retired_at && issuedAt >= Date.parse(key.retired_at)) { + return { + valid: false, + statusAtIssuance: "inactive", + result: "signed_after_retirement", + conclusion: "key_status_unknown", + needsAttestation: false, + }; + } + return { + valid: true, + statusAtIssuance: "active", + result: "retired_key_valid_at_issuance", + conclusion: "retired_key_valid_at_issuance", + needsAttestation: false, + }; + } + if (issuedAt > validUntil) { + return { + valid: false, + statusAtIssuance: "inactive", + result: "key_expired", + conclusion: "key_status_unknown", + needsAttestation: false, + }; + } + return { + valid: key.status === "active", + statusAtIssuance: key.status === "active" ? "active" : "inactive", + result: key.status === "active" ? "active_key_valid_at_issuance" : "key_status_unknown", + conclusion: key.status === "active" ? "valid_at_issuance" : "key_status_unknown", + needsAttestation: false, + }; +} + +function emptyTrustResult( + event: Partial, + trustMode: OpenReceiptTrustMode, +): TrustAwareVerificationResult { + return { + overall_status: "invalid", + signature: { valid: false, algorithm: null, kid: null }, + issuer: { + claimed: event.issuer?.id ?? null, + resolved: false, + trusted: false, + trust_mode: trustMode, + }, + metadata: { + version: null, + hash_valid: null, + signature_valid: null, + chain_valid: null, + issued_at: null, + next_update: null, + resolved_at: null, + age_seconds: null, + freshness: "unknown", + as_of: null, + key_valid_as_of_metadata: null, + }, + key: { + purpose: null, + status_at_issuance: "unknown", + current_status: "unknown", + historical_result: "key_status_unknown", + conclusion: "key_status_unknown", + }, + issuance_attestation: { status: "missing", sequence: null }, + metadata_as_of: null, + metadata_freshness: "unknown", + key_valid_as_of_metadata: null, + warnings: [], + errors: [], + }; +} + +export async function verifyOpenReceiptTrust( + value: unknown, + options: { + trustMode?: OpenReceiptTrustMode; + resolver?: IssuerResolver; + pinnedMetadata?: IssuerMetadataV02; + pinnedMetadataHash?: string; + previousMetadata?: IssuerMetadataV02[]; + resolvedAt?: string; + now?: string; + maxMetadataAgeSeconds?: number; + } = {}, +): Promise { + const trustMode = options.trustMode ?? "embedded_only"; + const event = (isObject(value) ? value : {}) as unknown as OpenReceiptEventV02; + const result = emptyTrustResult(event, trustMode); + const schemaErrors = validateV02Event(value); + if (schemaErrors.length > 0) { + result.errors.push(...schemaErrors); + return result; + } + let parsed: ReturnType; + try { + parsed = parseDetachedJws(event.signature); + result.signature.algorithm = "EdDSA"; + result.signature.kid = parsed.header.kid; + } catch (error) { + result.errors.push(error instanceof Error ? error.message : "malformed_signature"); + return result; + } + if (parsed.header.kid !== event.signing_key_id) { + result.errors.push("signing_key_id_mismatch"); + return result; + } + + let metadata: IssuerMetadataV02 | null = null; + let metadataTrusted = false; + let resolvedAt = options.resolvedAt ?? new Date().toISOString(); + let previousMetadata = options.previousMetadata ?? []; + if (trustMode === "pinned_metadata") { + metadata = options.pinnedMetadata ?? null; + if (metadata) { + const actualHash = await issuerMetadataHash(metadata); + metadataTrusted = + typeof options.pinnedMetadataHash === "string" && actualHash === options.pinnedMetadataHash; + if (!metadataTrusted) result.errors.push("pinned_metadata_hash_mismatch"); + } else if (options.resolver?.trustMode === "pinned_metadata") { + try { + const resolution = await options.resolver.resolve({ + issuer: event.issuer.id, + metadataVersion: event.issuer_metadata_version, + metadataHash: event.issuer_metadata_hash, + }); + metadata = resolution.metadata; + metadataTrusted = resolution.trusted; + resolvedAt = resolution.resolved_at; + previousMetadata = resolution.previous_metadata ?? []; + } catch (error) { + result.errors.push( + error instanceof Error ? error.message : "pinned_metadata_resolution_failed", + ); + } + } else { + result.errors.push("pinned_metadata_missing"); + } + } else if (trustMode === "https_webpki") { + if (!options.resolver || options.resolver.trustMode !== "https_webpki") { + result.errors.push("https_resolver_missing"); + } else { + try { + const resolution = await options.resolver.resolve({ + issuer: event.issuer.id, + metadataVersion: event.issuer_metadata_version, + metadataHash: event.issuer_metadata_hash, + }); + metadata = resolution.metadata; + metadataTrusted = resolution.trusted; + resolvedAt = resolution.resolved_at; + previousMetadata = resolution.previous_metadata ?? []; + } catch (error) { + result.errors.push(error instanceof Error ? error.message : "issuer_resolution_failed"); + } + } + } + + let verificationKey: Ed25519PublicJwk | undefined; + let keyRecord: IssuerMetadataKeyV02 | undefined; + let keyEvaluation: KeyEvaluation | null = null; + if (metadata) { + const metadataVerification = await verifyIssuerMetadata(metadata, previousMetadata.at(-1)); + const pinnedSnapshotAnchor = + trustMode === "pinned_metadata" && metadataTrusted && previousMetadata.length === 0; + const actualHash = metadataVerification.hash; + const hashValid = + actualHash === event.issuer_metadata_hash && + metadata.metadata_version === event.issuer_metadata_version; + const fullChain = + previousMetadata.length === 0 + ? pinnedSnapshotAnchor || metadataVerification.chain_valid + : (await verifyIssuerMetadataChain([...previousMetadata, metadata])).valid; + result.errors.push( + ...metadataVerification.errors.filter( + (error) => !(pinnedSnapshotAnchor && error === "metadata_hash_chain_invalid"), + ), + ); + if (!hashValid) result.errors.push("metadata_hash_or_version_mismatch"); + if (!fullChain && !result.errors.includes("metadata_hash_chain_invalid")) { + result.errors.push("metadata_hash_chain_invalid"); + } + result.metadata.version = metadata.metadata_version; + result.metadata.hash_valid = hashValid; + result.metadata.signature_valid = metadataVerification.signature_valid; + result.metadata.chain_valid = fullChain; + result.metadata.issued_at = metadata.issued_at; + result.metadata.next_update = metadata.next_update; + result.metadata.resolved_at = resolvedAt; + result.metadata.as_of = metadata.issued_at; + result.metadata_as_of = metadata.issued_at; + result.issuer.resolved = metadata.issuer === event.issuer.id; + result.issuer.trusted = + metadataTrusted && + result.issuer.resolved && + hashValid && + metadataVerification.signature_valid && + fullChain; + keyRecord = metadata.keys.find((candidate) => candidate.kid === event.signing_key_id); + if (!keyRecord) { + result.errors.push("key_unknown"); + result.key.historical_result = "key_unknown"; + } else if (keyRecord.purpose !== "open_receipt_evidence") { + result.errors.push("key_purpose_mismatch"); + result.key.purpose = keyRecord.purpose; + result.key.current_status = keyRecord.status; + result.key.historical_result = "key_purpose_mismatch"; + } else { + verificationKey = keyRecord.public_jwk; + result.key.purpose = keyRecord.purpose; + result.key.current_status = keyRecord.status; + keyEvaluation = evaluateHistoricalKey(keyRecord, Date.parse(event.issued_at)); + result.key.status_at_issuance = keyEvaluation.statusAtIssuance; + result.key.historical_result = keyEvaluation.result; + result.key.conclusion = keyEvaluation.conclusion; + result.metadata.key_valid_as_of_metadata = keyEvaluation.valid; + result.key_valid_as_of_metadata = keyEvaluation.valid; + } + + const now = Date.parse(options.now ?? new Date().toISOString()); + const issued = Date.parse(metadata.issued_at); + const resolved = Date.parse(resolvedAt); + const nextUpdate = Date.parse(metadata.next_update); + const maxAge = Math.max(0, options.maxMetadataAgeSeconds ?? 86_400); + result.metadata.age_seconds = Math.max(0, Math.floor((now - issued) / 1000)); + result.metadata.freshness = + now <= nextUpdate && now - resolved <= maxAge * 1000 ? "fresh" : "stale"; + result.metadata_freshness = result.metadata.freshness; + } else if (trustMode === "embedded_only") { + const embedded = event.issuer.verification_key; + if (embedded?.kid === event.signing_key_id) verificationKey = embedded; + result.key.historical_result = "key_status_unknown"; + result.key.conclusion = "key_status_unknown"; + result.warnings.push("issuer_untrusted", "key_status_unknown"); + } + + if (verificationKey) { + const payloadSegment = bytesToBase64Url(encoder.encode(canonicalize(unsignedV02(event)))); + try { + result.signature.valid = await crypto.subtle.verify( + { name: "Ed25519" }, + await importVerificationKey(verificationKey), + parsed.signature as BufferSource, + encoder.encode(`${parsed.protectedSegment}.${payloadSegment}`) as BufferSource, + ); + } catch { + result.signature.valid = false; + } + if (!result.signature.valid) result.errors.push("bad_signature"); + } else if (!result.errors.includes("key_unknown")) { + result.errors.push("verification_key_not_found"); + } + + const attestation = metadata + ? await verifyIssuanceAttestation(event, metadata) + : { + status: event.issuance_attestation ? ("invalid" as const) : ("missing" as const), + sequence: event.issuance_attestation?.sequence ?? null, + errors: event.issuance_attestation ? ["attestation_metadata_unavailable"] : [], + }; + result.issuance_attestation = { + status: attestation.status, + sequence: attestation.sequence, + }; + result.errors.push(...attestation.errors); + if (attestation.status === "missing") result.warnings.push("issuance_attestation_missing"); + if (attestation.status === "invalid") result.errors.push("issuance_attestation_invalid"); + + if (keyEvaluation?.needsAttestation && attestation.status !== "valid") { + result.warnings.push("backdating_resistance_insufficient"); + if (keyEvaluation.result === "signed_before_compromise") { + result.key.conclusion = "key_status_unknown"; + } + } + if (attestation.status === "valid") result.warnings.push("issuance_attestation_valid"); + if (result.metadata.freshness === "stale") { + result.warnings.push("metadata_stale"); + if ( + result.key.conclusion === "valid_at_issuance" || + result.key.conclusion === "retired_key_valid_at_issuance" + ) { + result.key.conclusion = "valid_but_metadata_stale"; + } + } + if (!result.issuer.trusted && trustMode !== "embedded_only") { + result.errors.push("issuer_untrusted"); + } + + const hardInvalid = + !result.signature.valid || + result.errors.some((error) => + [ + "bad_signature", + "key_unknown", + "key_purpose_mismatch", + "signed_after_revocation", + "signed_after_compromise", + "metadata_signature_invalid", + "metadata_hash_chain_invalid", + "metadata_hash_or_version_mismatch", + "metadata_signing_key_invalid_at_issuance", + "attestation_signature_invalid", + "attestation_receipt_digest_mismatch", + "issuance_attestation_invalid", + ].includes(error), + ) || + keyEvaluation?.result === "signed_after_revocation" || + keyEvaluation?.result === "signed_after_compromise" || + keyEvaluation?.result === "signed_after_retirement" || + keyEvaluation?.result === "key_expired"; + const indeterminate = + trustMode === "embedded_only" || + !result.issuer.trusted || + keyEvaluation?.result === "compromise_time_unknown" || + (keyEvaluation?.needsAttestation && attestation.status !== "valid"); + result.overall_status = hardInvalid + ? "invalid" + : indeterminate + ? "indeterminate" + : result.warnings.length > 0 + ? "valid_with_warnings" + : "valid"; + return result; +} + +export function createPinnedMetadataResolver(input: { + metadata: IssuerMetadataV02; + metadataHash: string; + previousMetadata?: IssuerMetadataV02[]; + resolvedAt?: string; +}): IssuerResolver { + return { + trustMode: "pinned_metadata", + async resolve(request) { + const actualHash = await issuerMetadataHash(input.metadata); + if ( + request.issuer !== input.metadata.issuer || + request.metadataVersion !== input.metadata.metadata_version || + request.metadataHash !== actualHash || + input.metadataHash !== actualHash + ) { + throw new Error("pinned_metadata_mismatch"); + } + return { + metadata: input.metadata, + trust_mode: "pinned_metadata", + resolved_at: input.resolvedAt ?? new Date().toISOString(), + trusted: true, + ...(input.previousMetadata ? { previous_metadata: input.previousMetadata } : {}), + }; + }, + }; +} + +function unsafeIssuerHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") + ) { + return true; + } + if (/^\d+\.\d+\.\d+\.\d+$/.test(normalized)) { + const octets = normalized.split(".").map(Number); + return ( + octets[0] === 10 || + octets[0] === 127 || + (octets[0] === 169 && octets[1] === 254) || + (octets[0] === 172 && (octets[1] ?? 0) >= 16 && (octets[1] ?? 0) <= 31) || + (octets[0] === 192 && octets[1] === 168) + ); + } + return ( + normalized === "::1" || + normalized === "[::1]" || + normalized.startsWith("[fc") || + normalized.startsWith("[fd") || + normalized.startsWith("[fe8") || + normalized.startsWith("[fe9") || + normalized.startsWith("[fea") || + normalized.startsWith("[feb") + ); +} + +export function createHttpsWebPkiResolver(options: { + allowedIssuerOrigins: string[]; + fetch?: typeof globalThis.fetch; + timeoutMs?: number; + maximumResponseBytes?: number; + now?: () => Date; +}): IssuerResolver { + const fetcher = options.fetch ?? globalThis.fetch; + const allowed = new Set( + options.allowedIssuerOrigins.map((origin) => { + const parsed = new URL(origin); + if (parsed.protocol !== "https:") throw new TypeError("issuer_allowlist_requires_https"); + return parsed.origin; + }), + ); + const cache = new Map(); + async function fetchVersion( + issuer: URL, + metadataVersion: number, + expectedHash: string, + ): Promise<{ metadata: IssuerMetadataV02; hash: string; etag?: string }> { + const key = `${issuer.origin}:${metadataVersion}`; + const cached = cache.get(key); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 5_000); + let response: Response; + try { + response = await fetcher( + `${issuer.origin}/.well-known/receipt-issuer/versions/${metadataVersion}`, + { + method: "GET", + redirect: "manual", + signal: controller.signal, + headers: cached?.etag ? { "if-none-match": cached.etag } : {}, + }, + ); + } finally { + clearTimeout(timeout); + } + if (response.status === 304 && cached) { + if (cached.hash !== expectedHash) throw new Error("metadata_hash_mismatch"); + return cached; + } + if (response.status >= 300 && response.status < 400) { + throw new Error("issuer_redirect_rejected"); + } + if (!response.ok) throw new Error(`issuer_metadata_http_${response.status}`); + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if ( + !contentType.startsWith("application/json") && + !contentType.startsWith("application/open-receipt-issuer+json") + ) { + throw new Error("issuer_metadata_content_type_invalid"); + } + const maximum = options.maximumResponseBytes ?? 262_144; + const contentLength = Number(response.headers.get("content-length") ?? "0"); + if (contentLength > maximum) throw new Error("issuer_metadata_too_large"); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maximum) throw new Error("issuer_metadata_too_large"); + const metadata = JSON.parse(new TextDecoder().decode(bytes)) as IssuerMetadataV02; + const hash = await issuerMetadataHash(metadata); + if ( + metadata.issuer !== issuer.origin || + metadata.metadata_version !== metadataVersion || + hash !== expectedHash + ) { + throw new Error("metadata_hash_or_version_mismatch"); + } + if (cached && cached.hash !== hash) throw new Error("metadata_version_rollback_or_rewrite"); + const etag = response.headers.get("etag") ?? undefined; + const stored = { metadata, hash, ...(etag ? { etag } : {}) }; + cache.set(key, stored); + return stored; + } + return { + trustMode: "https_webpki", + async resolve(request) { + const issuer = new URL(request.issuer); + if ( + issuer.protocol !== "https:" || + issuer.origin !== request.issuer.replace(/\/$/, "") || + !allowed.has(issuer.origin) || + unsafeIssuerHostname(issuer.hostname) + ) { + throw new Error("issuer_origin_not_allowed"); + } + if (!Number.isSafeInteger(request.metadataVersion) || request.metadataVersion < 1) { + throw new Error("invalid_metadata_version"); + } + if (request.metadataVersion > 10_000) throw new Error("metadata_chain_too_long"); + const current = await fetchVersion(issuer, request.metadataVersion, request.metadataHash); + const previous: IssuerMetadataV02[] = []; + let expectedPreviousHash = current.metadata.previous_metadata_hash; + for (let version = request.metadataVersion - 1; version >= 1; version -= 1) { + if (!expectedPreviousHash) throw new Error("metadata_hash_chain_invalid"); + const resolved = await fetchVersion(issuer, version, expectedPreviousHash); + previous.unshift(resolved.metadata); + expectedPreviousHash = resolved.metadata.previous_metadata_hash; + } + if (expectedPreviousHash !== null) throw new Error("metadata_hash_chain_invalid"); + return { + metadata: current.metadata, + trust_mode: "https_webpki", + resolved_at: (options.now ?? (() => new Date()))().toISOString(), + trusted: true, + ...(current.etag ? { etag: current.etag } : {}), + ...(previous.length > 0 ? { previous_metadata: previous } : {}), + }; + }, + }; +} diff --git a/test-vectors/v0.2/active-evidence-key.valid.json b/test-vectors/v0.2/active-evidence-key.valid.json new file mode 100644 index 0000000..c5065f2 --- /dev/null +++ b/test-vectors/v0.2/active-evidence-key.valid.json @@ -0,0 +1,43 @@ +{ + "spec_version": "0.2", + "event_id": "evt_open_receipt_v02_example", + "event_type": "settlement.completed", + "issuer": { + "id": "https://issuer.example", + "name": "Open Receipt deterministic example issuer", + "metadata_url": "https://issuer.example/.well-known/receipt-issuer", + "verification_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "kid": "example-evidence-2026-q3", + "use": "sig", + "alg": "EdDSA" + } + }, + "issued_at": "2026-07-30T12:01:00.000Z", + "transaction_id": "tx_open_receipt_v02_example", + "quote_id": "quote_open_receipt_v02_example", + "commercial_facts": { "buyer_total_cents": 5, "currency": "USD" }, + "evidence": { "delivered": true, "result_count": 1 }, + "provenance": { "provider": "deterministic-test-provider" }, + "assurance": "validated", + "parent_event_hashes": [], + "signing_key_id": "example-evidence-2026-q3", + "issuer_metadata_version": 1, + "issuer_metadata_hash": "sha256:3e50148f7536c1c8fc881424bc7f9363231220cd468c5fb27917d882d338dde2", + "issuance_attestation": { + "type": "open_receipt_issuance_attestation", + "version": 1, + "issuer": "https://issuer.example", + "sequence": 1, + "receipt_digest": "sha256:e19d879efd80e38a3103c1a5f45aeb3efab4f9771d6886572d08cde5772750f4", + "evidence_signing_key_id": "example-evidence-2026-q3", + "issued_at": "2026-07-30T12:01:00.000Z", + "previous_entry_hash": null, + "log_signing_key_id": "example-log-2026-q3", + "entry_hash": "sha256:e1a3a149c65d01abe00adde043f45161a0739f62b567cda332acb0ec57bc33b6", + "signature": "RH19gFdKIsPuakhfRMl6Y9F6jq_Dm--KirT3pJpRGl3tnbVxmzH1M9CQqKoFmZXjwVo0aV3MnkxuRzqtEbElBQ" + }, + "signature": "eyJhbGciOiJFZERTQSIsImtpZCI6ImV4YW1wbGUtZXZpZGVuY2UtMjAyNi1xMyIsInR5cCI6Im9wZW4tcmVjZWlwdCtqd3MifQ..D5dT2vKKUMKzjv2PDj2j6PClYD2a1lkp3lwOPpTTKxpOuV4zlRzT97hzVUOXLNFuLTLoZvyt7QFBJ4DRz0U4AA" +} diff --git a/test-vectors/v0.2/issuer-metadata.valid.json b/test-vectors/v0.2/issuer-metadata.valid.json new file mode 100644 index 0000000..633170d --- /dev/null +++ b/test-vectors/v0.2/issuer-metadata.valid.json @@ -0,0 +1,90 @@ +{ + "schema": "https://receiptprotocol.com/open-receipt/v0.2/issuer-metadata.schema.json", + "issuer": "https://issuer.example", + "metadata_version": 1, + "issued_at": "2026-07-30T12:00:00.000Z", + "next_update": "2026-08-30T12:00:00.000Z", + "previous_metadata_hash": null, + "keys": [ + { + "kid": "example-evidence-2026-q3", + "purpose": "open_receipt_evidence", + "alg": "EdDSA", + "public_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "kid": "example-evidence-2026-q3", + "use": "sig", + "alg": "EdDSA" + }, + "status": "active", + "valid_from": "2026-07-01T00:00:00.000Z", + "valid_until": null, + "activated_at": "2026-07-01T00:00:00.000Z", + "retired_at": null, + "revoked_at": null, + "revocation_effective_from": null, + "compromised_at": null, + "compromise_effective_from": null, + "destroyed_at": null, + "replaced_by_kid": null, + "reason_code": null + }, + { + "kid": "example-metadata-2026-q3", + "purpose": "issuer_metadata", + "alg": "EdDSA", + "public_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "PUAXw-hDiVqStwqnTRt-vJyYLM8uxJaMwM1V8Sr0Zgw", + "kid": "example-metadata-2026-q3", + "use": "sig", + "alg": "EdDSA" + }, + "status": "active", + "valid_from": "2026-07-01T00:00:00.000Z", + "valid_until": null, + "activated_at": "2026-07-01T00:00:00.000Z", + "retired_at": null, + "revoked_at": null, + "revocation_effective_from": null, + "compromised_at": null, + "compromise_effective_from": null, + "destroyed_at": null, + "replaced_by_kid": null, + "reason_code": null + }, + { + "kid": "example-log-2026-q3", + "purpose": "issuance_log", + "alg": "EdDSA", + "public_jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_FHNjmIYoaONpH7QAjDwWAgW7RO6MwOsXeuRFUiQgCU", + "kid": "example-log-2026-q3", + "use": "sig", + "alg": "EdDSA" + }, + "status": "active", + "valid_from": "2026-07-01T00:00:00.000Z", + "valid_until": null, + "activated_at": "2026-07-01T00:00:00.000Z", + "retired_at": null, + "revoked_at": null, + "revocation_effective_from": null, + "compromised_at": null, + "compromise_effective_from": null, + "destroyed_at": null, + "replaced_by_kid": null, + "reason_code": null + } + ], + "signature": { + "kid": "example-metadata-2026-q3", + "alg": "EdDSA", + "value": "Wi-4nYVg2BL7tk-Hs1KaN42rHgpe8Ar2EiGbCFnlvc7vELLtYh17MQorcFe40iwUjeuk-aXMcXujYUg9bQoJBA" + } +} diff --git a/test-vectors/v0.2/manifest.json b/test-vectors/v0.2/manifest.json new file mode 100644 index 0000000..41cd7a5 --- /dev/null +++ b/test-vectors/v0.2/manifest.json @@ -0,0 +1,67 @@ +{ + "spec_version": "0.2", + "description": "Deterministic conformance vector catalog. The canonical signed fixtures are constructed from fixed RFC 8032 test keys by packages/open-receipt/tests/v02.test.ts.", + "test_key_warning": "Test keys only. Never use these keys for production.", + "vectors": [ + { + "id": 1, + "name": "active_evidence_key_trusted_issuer_fresh_metadata", + "expected": "valid_at_issuance", + "artifact": "active-evidence-key.valid.json", + "issuer_metadata": "issuer-metadata.valid.json" + }, + { "id": 2, "name": "embedded_key_valid_issuer_untrusted", "expected": "indeterminate" }, + { "id": 3, "name": "pinned_metadata", "expected": "valid_with_warnings" }, + { "id": 4, "name": "https_resolved_metadata", "expected": "valid_with_warnings" }, + { + "id": 5, + "name": "retired_key_issued_in_window", + "expected": "retired_key_valid_at_issuance" + }, + { "id": 6, "name": "signed_after_retirement", "expected": "invalid" }, + { + "id": 7, + "name": "signed_before_prospective_revocation", + "expected": "signed_before_revocation" + }, + { "id": 8, "name": "signed_after_revocation", "expected": "invalid" }, + { + "id": 9, + "name": "signed_before_compromise_valid_attestation", + "expected": "valid_with_warnings" + }, + { "id": 10, "name": "signed_after_compromise", "expected": "invalid" }, + { "id": 11, "name": "compromise_time_unknown", "expected": "indeterminate" }, + { "id": 12, "name": "offline_metadata_stale", "expected": "valid_but_metadata_stale" }, + { "id": 13, "name": "metadata_signature_tampered", "expected": "invalid" }, + { "id": 14, "name": "metadata_hash_chain_broken", "expected": "invalid" }, + { "id": 15, "name": "wrong_key_purpose", "expected": "invalid" }, + { "id": 16, "name": "unknown_kid", "expected": "invalid" }, + { "id": 17, "name": "key_not_yet_valid", "expected": "invalid" }, + { "id": 18, "name": "key_expired", "expected": "invalid" }, + { "id": 19, "name": "valid_issuance_log_attestation", "expected": "valid" }, + { "id": 20, "name": "invalid_issuance_log_signature", "expected": "invalid" }, + { "id": 21, "name": "issuance_receipt_digest_mismatch", "expected": "invalid" }, + { "id": 22, "name": "v01_without_issuance_attestation", "expected": "valid_lower_assurance" }, + { "id": 23, "name": "cross_purpose_signing_blocked", "expected": "blocked" }, + { "id": 24, "name": "planned_key_rotation", "expected": "old_retired_new_preactive" }, + { + "id": 25, + "name": "emergency_compromise_replacement", + "expected": "compromised_with_replacement" + }, + { "id": 26, "name": "historical_receipt_after_retirement", "expected": "valid" }, + { + "id": 27, + "name": "backdated_compromised_key_missing_attestation", + "expected": "indeterminate" + }, + { + "id": 28, + "name": "equivalent_timestamp_representations", + "expected": "identical_canonical_bytes" + }, + { "id": 29, "name": "invalid_timestamp", "expected": "fail_closed" }, + { "id": 30, "name": "metadata_version_replay_or_rollback", "expected": "rejected" } + ] +} diff --git a/tests/v02.test.ts b/tests/v02.test.ts new file mode 100644 index 0000000..ab33007 --- /dev/null +++ b/tests/v02.test.ts @@ -0,0 +1,636 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + canonicalize, + createHttpsWebPkiResolver, + createPinnedMetadataResolver, + issuerMetadataHash, + receiptDigestForAttestation, + signIssuanceAttestation, + signIssuerMetadata, + signOpenReceipt, + signOpenReceiptV02, + verifyIssuerMetadata, + verifyIssuerMetadataChain, + verifyOpenReceipt, + verifyOpenReceiptTrust, + type Ed25519PublicJwk, + type IssuerMetadataKeyV02, + type IssuerMetadataV02, + type OpenReceiptEvent, + type OpenReceiptEventV02, + type UnsignedIssuerMetadataV02, +} from "../src"; + +const ISSUER = "https://issuer.example"; +const META_URL = `${ISSUER}/.well-known/receipt-issuer`; +// Public RFC 8032 deterministic test material only. Never use these seeds for +// production or any identity-bearing key. +const EVIDENCE_SEED = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; +const EVIDENCE_PUBLIC = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; +const METADATA_SEED = "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb"; +const METADATA_PUBLIC = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"; +const LOG_SEED = "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7"; +const LOG_PUBLIC = "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"; +const PKCS8_PREFIX = "302e020100300506032b657004220420"; + +function hexBytes(hex: string): Uint8Array { + return Uint8Array.from(hex.match(/.{2}/g) ?? [], (byte) => Number.parseInt(byte, 16)); +} + +function base64Url(hex: string): string { + return Buffer.from(hexBytes(hex)).toString("base64url"); +} + +async function privateKey(seed: string): Promise { + return crypto.subtle.importKey( + "pkcs8", + hexBytes(PKCS8_PREFIX + seed) as BufferSource, + { name: "Ed25519" }, + false, + ["sign"], + ); +} + +function jwk(kid: string, publicHex: string): Ed25519PublicJwk { + return { + kty: "OKP", + crv: "Ed25519", + x: base64Url(publicHex), + kid, + use: "sig", + alg: "EdDSA", + }; +} + +const evidenceJwk = jwk("evidence-2026-q3", EVIDENCE_PUBLIC); +const metadataJwk = jwk("metadata-2026-q3", METADATA_PUBLIC); +const logJwk = jwk("log-2026-q3", LOG_PUBLIC); + +function metadataKey( + purpose: IssuerMetadataKeyV02["purpose"], + key: Ed25519PublicJwk, + overrides: Partial = {}, +): IssuerMetadataKeyV02 { + return { + kid: key.kid, + purpose, + alg: "EdDSA", + public_jwk: key, + status: "active", + valid_from: "2026-07-01T00:00:00Z", + valid_until: null, + activated_at: "2026-07-01T00:00:00Z", + retired_at: null, + revoked_at: null, + revocation_effective_from: null, + compromised_at: null, + compromise_effective_from: null, + destroyed_at: null, + replaced_by_kid: null, + reason_code: null, + ...overrides, + }; +} + +async function metadata( + evidenceOverrides: Partial = {}, + input: Partial = {}, +): Promise { + const unsigned: UnsignedIssuerMetadataV02 = { + schema: "https://receiptprotocol.com/open-receipt/v0.2/issuer-metadata.schema.json", + issuer: ISSUER, + metadata_version: 1, + issued_at: "2026-07-30T12:00:00Z", + next_update: "2026-07-31T12:00:00Z", + previous_metadata_hash: null, + keys: [ + metadataKey("open_receipt_evidence", evidenceJwk, evidenceOverrides), + metadataKey("issuer_metadata", metadataJwk), + metadataKey("issuance_log", logJwk), + ], + ...input, + }; + return signIssuerMetadata(unsigned, await privateKey(METADATA_SEED), metadataJwk.kid); +} + +async function event( + issuerMetadata: IssuerMetadataV02, + issuedAt = "2026-07-30T12:01:00Z", + options: { kid?: string; attestation?: boolean; key?: CryptoKey } = {}, +): Promise { + const kid = options.kid ?? evidenceJwk.kid; + const unsigned: Omit = { + spec_version: "0.2", + event_id: "evt_test_1", + event_type: "settlement.completed", + issuer: { + id: ISSUER, + name: "Test issuer", + metadata_url: META_URL, + verification_key: { ...evidenceJwk, kid }, + }, + issued_at: issuedAt, + transaction_id: "tx_test_1", + quote_id: "quote_test_1", + commercial_facts: { buyer_total_cents: 5, currency: "USD" }, + evidence: { delivered: true }, + provenance: { provider: "test" }, + assurance: "validated", + parent_event_hashes: [], + signing_key_id: kid, + issuer_metadata_version: issuerMetadata.metadata_version, + issuer_metadata_hash: await issuerMetadataHash(issuerMetadata), + }; + if (options.attestation) { + const receiptDigest = await receiptDigestForAttestation({ ...unsigned, signature: "" }); + unsigned.issuance_attestation = await signIssuanceAttestation( + { + type: "open_receipt_issuance_attestation", + version: 1, + issuer: ISSUER, + sequence: 7, + receipt_digest: receiptDigest, + evidence_signing_key_id: kid, + issued_at: issuedAt, + previous_entry_hash: `sha256:${"1".repeat(64)}`, + log_signing_key_id: logJwk.kid, + }, + await privateKey(LOG_SEED), + ); + } + return signOpenReceiptV02(unsigned, options.key ?? (await privateKey(EVIDENCE_SEED))); +} + +async function pinned( + value: OpenReceiptEventV02, + issuerMetadata: IssuerMetadataV02, + now = "2026-07-30T12:02:00Z", +) { + return verifyOpenReceiptTrust(value, { + trustMode: "pinned_metadata", + pinnedMetadata: issuerMetadata, + pinnedMetadataHash: await issuerMetadataHash(issuerMetadata), + resolvedAt: "2026-07-30T12:01:30Z", + now, + }); +} + +describe("Open Receipt 0.2 public trust vectors", () => { + it("publishes the complete 30-vector catalog", () => { + const manifest = JSON.parse( + readFileSync( + resolve(import.meta.dir, "../test-vectors/v0.2/manifest.json"), + "utf8", + ), + ) as { vectors: Array<{ id: number }> }; + expect(manifest.vectors.map((vector) => vector.id)).toEqual( + Array.from({ length: 30 }, (_, index) => index + 1), + ); + }); + + it("01 active evidence key, trusted issuer, fresh metadata", async () => { + const document = await metadata(); + const result = await pinned(await event(document), document); + expect(result.overall_status).toBe("valid_with_warnings"); + expect(result.signature.valid).toBe(true); + expect(result.issuer.trusted).toBe(true); + expect(result.key.conclusion).toBe("valid_at_issuance"); + }); + + it("verifies canonical sha256-prefixed v0.2 parent references", async () => { + const issuerMetadata = await metadata(); + const base = await event(issuerMetadata); + const { signature: _signature, ...unsigned } = base; + const canonical = await signOpenReceiptV02( + { ...unsigned, parent_event_hashes: [`sha256:${"a".repeat(64)}`] }, + await privateKey(EVIDENCE_SEED), + ); + const result = await pinned(canonical, issuerMetadata); + expect(result.signature.valid).toBe(true); + }); + + it("verifies immutable early-v0.2 events that used a bare SHA-256 parent digest", async () => { + const issuerMetadata = await metadata(); + const base = await event(issuerMetadata); + const { signature: _signature, ...unsigned } = base; + const historical = await signOpenReceiptV02( + { ...unsigned, parent_event_hashes: ["a".repeat(64)] }, + await privateKey(EVIDENCE_SEED), + ); + const result = await pinned(historical, issuerMetadata); + expect(result.signature.valid).toBe(true); + }); + + it("02 embedded key is cryptographically valid but issuer is untrusted", async () => { + const document = await metadata(); + const result = await verifyOpenReceiptTrust(await event(document)); + expect(result.signature.valid).toBe(true); + expect(result.issuer.trusted).toBe(false); + expect(result.overall_status).toBe("indeterminate"); + }); + + it("03 pinned metadata verification", async () => { + const document = await metadata( + {}, + { + metadata_version: 12, + previous_metadata_hash: `sha256:${"a".repeat(64)}`, + }, + ); + const documentHash = await issuerMetadataHash(document); + const result = await verifyOpenReceiptTrust(await event(document), { + trustMode: "pinned_metadata", + resolver: createPinnedMetadataResolver({ + metadata: document, + metadataHash: documentHash, + resolvedAt: "2026-07-30T12:01:30Z", + }), + now: "2026-07-30T12:02:00Z", + }); + expect(result.issuer.trust_mode).toBe("pinned_metadata"); + expect(result.issuer.trusted).toBe(true); + expect(result.metadata.chain_valid).toBe(true); + }); + + it("04 HTTPS-resolved metadata verification", async () => { + const document = await metadata(); + const signed = await event(document); + const resolver = createHttpsWebPkiResolver({ + allowedIssuerOrigins: [ISSUER], + now: () => new Date("2026-07-30T12:01:30Z"), + fetch: async () => + new Response(JSON.stringify(document), { + headers: { "content-type": "application/open-receipt-issuer+json", etag: '"v1"' }, + }), + }); + const result = await verifyOpenReceiptTrust(signed, { + trustMode: "https_webpki", + resolver, + now: "2026-07-30T12:02:00Z", + }); + expect(result.issuer.trusted).toBe(true); + }); + + it("05 retired key remains valid during its issuance window", async () => { + const document = await metadata({ + status: "retired", + retired_at: "2026-08-01T00:00:00Z", + valid_until: "2026-08-01T00:00:00Z", + }); + expect((await pinned(await event(document), document)).key.historical_result).toBe( + "retired_key_valid_at_issuance", + ); + }); + + it("06 signing after retirement is invalid", async () => { + const document = await metadata({ + status: "retired", + retired_at: "2026-07-29T00:00:00Z", + valid_until: "2026-08-02T00:00:00Z", + }); + expect((await pinned(await event(document), document)).key.historical_result).toBe( + "signed_after_retirement", + ); + }); + + it("07 signing before prospective revocation remains historically valid", async () => { + const document = await metadata({ + status: "revoked", + revoked_at: "2026-08-01T00:00:00Z", + }); + expect((await pinned(await event(document), document)).key.historical_result).toBe( + "signed_before_revocation", + ); + }); + + it("08 signing at or after revocation is invalid", async () => { + const document = await metadata({ + status: "revoked", + revoked_at: "2026-07-30T12:00:00Z", + }); + const result = await pinned(await event(document), document); + expect(result.key.conclusion).toBe("signed_after_revocation"); + expect(result.overall_status).toBe("invalid"); + }); + + it("09 signing before compromise with a valid issuance attestation", async () => { + const document = await metadata({ + status: "compromised", + compromised_at: "2026-08-01T00:00:00Z", + compromise_effective_from: "2026-08-01T00:00:00Z", + }); + const result = await pinned(await event(document, undefined, { attestation: true }), document); + expect(result.issuance_attestation.status).toBe("valid"); + expect(result.key.historical_result).toBe("signed_before_compromise"); + }); + + it("10 signing after compromise is invalid", async () => { + const document = await metadata({ + status: "compromised", + compromised_at: "2026-07-30T12:00:00Z", + compromise_effective_from: "2026-07-30T12:00:00Z", + }); + expect((await pinned(await event(document), document)).key.conclusion).toBe( + "signed_after_compromise", + ); + }); + + it("11 unknown compromise time is indeterminate", async () => { + const document = await metadata({ + status: "compromised", + compromised_at: "2026-08-01T00:00:00Z", + compromise_effective_from: null, + }); + expect((await pinned(await event(document), document)).key.historical_result).toBe( + "compromise_time_unknown", + ); + }); + + it("12 stale offline metadata reports its knowledge boundary", async () => { + const document = await metadata(); + const result = await pinned(await event(document), document, "2026-08-03T12:00:00Z"); + expect(result.metadata_as_of).toBe("2026-07-30T12:00:00.000Z"); + expect(result.metadata_freshness).toBe("stale"); + expect(result.key_valid_as_of_metadata).toBe(true); + }); + + it("13 tampered metadata signature fails", async () => { + const document = await metadata(); + document.signature.value = document.signature.value.replace(/.$/, "A"); + expect((await verifyIssuerMetadata(document)).signature_valid).toBe(false); + }); + + it("14 broken metadata hash chain fails", async () => { + const first = await metadata(); + const second = await metadata( + {}, + { + metadata_version: 2, + issued_at: "2026-07-31T12:00:00Z", + next_update: "2026-08-01T12:00:00Z", + previous_metadata_hash: `sha256:${"f".repeat(64)}`, + }, + ); + expect((await verifyIssuerMetadataChain([first, second])).valid).toBe(false); + }); + + it("15 wrong key purpose cannot validate evidence", async () => { + const document = await metadata(); + document.keys[0] = metadataKey("issuance_log", evidenceJwk); + document.signature = ( + await signIssuerMetadata( + (({ signature: _signature, ...unsigned }) => unsigned)(document), + await privateKey(METADATA_SEED), + metadataJwk.kid, + ) + ).signature; + const signed = await event(document); + expect((await pinned(signed, document)).key.historical_result).toBe("key_purpose_mismatch"); + }); + + it("16 unknown kid fails closed", async () => { + const document = await metadata(); + expect( + (await pinned(await event(document, undefined, { kid: "unknown" }), document)).key + .historical_result, + ).toBe("key_unknown"); + }); + + it("17 a key not yet valid fails closed", async () => { + const document = await metadata({ valid_from: "2026-08-01T00:00:00Z" }); + expect((await pinned(await event(document), document)).key.historical_result).toBe( + "key_not_yet_valid", + ); + }); + + it("18 an expired key fails closed", async () => { + const document = await metadata({ valid_until: "2026-07-29T00:00:00Z" }); + expect((await pinned(await event(document), document)).key.historical_result).toBe( + "key_expired", + ); + }); + + it("19 a valid issuance-log attestation verifies", async () => { + const document = await metadata(); + expect( + (await pinned(await event(document, undefined, { attestation: true }), document)) + .issuance_attestation.status, + ).toBe("valid"); + }); + + it("20 an invalid issuance-log signature fails", async () => { + const document = await metadata(); + const signed = await event(document, undefined, { attestation: true }); + signed.issuance_attestation!.signature += "A"; + expect((await pinned(signed, document)).issuance_attestation.status).toBe("invalid"); + }); + + it("21 an issuance receipt digest mismatch fails", async () => { + const document = await metadata(); + const signed = await event(document, undefined, { attestation: true }); + signed.issuance_attestation!.receipt_digest = `sha256:${"0".repeat(64)}`; + expect((await pinned(signed, document)).errors).toContain( + "attestation_receipt_digest_mismatch", + ); + }); + + it("22 a v0.1 receipt without issuance attestation still verifies", async () => { + const unsigned: Omit = { + spec_version: "0.1", + event_id: "evt_v01", + event_type: "settlement.completed", + issuer: { id: ISSUER, metadata_url: META_URL, verification_key: evidenceJwk }, + issued_at: "2026-07-30T12:01:00Z", + transaction_id: "tx_v01", + quote_id: null, + commercial_facts: {}, + evidence: {}, + provenance: {}, + assurance: "validated", + parent_event_hashes: [], + signing_key_id: evidenceJwk.kid, + }; + const signed = await signOpenReceipt(unsigned, await privateKey(EVIDENCE_SEED)); + expect((await verifyOpenReceipt(signed)).valid).toBe(true); + }); + + it("23 cross-purpose signing attempts are blocked", async () => { + const document = await metadata(); + const signed = await event(document); + const { signature: _signature, ...unsigned } = signed; + expect( + signOpenReceiptV02(unsigned, await privateKey(EVIDENCE_SEED), "commerce_quote"), + ).rejects.toThrow("key_purpose_mismatch"); + }); + + it("24 planned key rotation preserves the old public key", async () => { + const document = await metadata({ + status: "retired", + retired_at: "2026-08-01T00:00:00Z", + replaced_by_kid: "evidence-2026-q4", + }); + document.keys.push( + metadataKey("open_receipt_evidence", jwk("evidence-2026-q4", LOG_PUBLIC), { + status: "preactive", + activated_at: null, + }), + ); + expect(document.keys.map((key) => key.status)).toContain("retired"); + expect(document.keys.map((key) => key.status)).toContain("preactive"); + }); + + it("25 emergency compromise can designate a replacement", async () => { + const document = await metadata({ + status: "compromised", + compromised_at: "2026-07-30T12:00:00Z", + compromise_effective_from: "2026-07-30T12:00:00Z", + replaced_by_kid: "evidence-2026-q4", + reason_code: "key_material_exposed", + }); + expect(document.keys[0]!.status).toBe("compromised"); + expect(document.keys[0]!.replaced_by_kid).toBe("evidence-2026-q4"); + }); + + it("26 a historical receipt remains verifiable after retirement", async () => { + const document = await metadata({ + status: "retired", + retired_at: "2026-08-01T00:00:00Z", + }); + expect((await pinned(await event(document), document)).signature.valid).toBe(true); + }); + + it("27 a backdated compromised-key receipt without a log attestation is indeterminate", async () => { + const document = await metadata({ + status: "compromised", + compromised_at: "2026-08-01T00:00:00Z", + compromise_effective_from: "2026-08-01T00:00:00Z", + }); + expect((await pinned(await event(document), document)).overall_status).toBe("indeterminate"); + }); + + it("28 equivalent Z and +00:00 timestamps canonicalize identically", async () => { + const document = await metadata(); + const a = await event(document, "2026-07-30T12:01:00Z"); + const b = await event(document, "2026-07-30T12:01:00+00:00"); + expect(a.signature).toBe(b.signature); + expect(canonicalize(a)).toBe(canonicalize(b)); + }); + + it("29 invalid timestamps fail closed", async () => { + const document = await metadata(); + expect(event(document, "not-a-time")).rejects.toThrow("invalid_timestamp"); + }); + + it("30 metadata version replay or rewrite is rejected by the HTTPS cache", async () => { + const first = await metadata(); + const rewritten = { ...first, next_update: "2026-08-02T00:00:00.000Z" }; + let calls = 0; + const resolver = createHttpsWebPkiResolver({ + allowedIssuerOrigins: [ISSUER], + fetch: async () => + new Response(JSON.stringify(calls++ === 0 ? first : rewritten), { + headers: { "content-type": "application/json" }, + }), + }); + const firstHash = await issuerMetadataHash(first); + await resolver.resolve({ issuer: ISSUER, metadataVersion: 1, metadataHash: firstHash }); + await expect( + resolver.resolve({ + issuer: ISSUER, + metadataVersion: 1, + metadataHash: await issuerMetadataHash(rewritten), + }), + ).rejects.toThrow("metadata_version_rollback_or_rewrite"); + }); + + it("rejects an issuance attestation made by a compromised log key", async () => { + const document = await metadata( + {}, + { + keys: [ + metadataKey("open_receipt_evidence", evidenceJwk), + metadataKey("issuer_metadata", metadataJwk), + metadataKey("issuance_log", logJwk, { + status: "compromised", + compromised_at: "2026-07-30T12:00:00Z", + compromise_effective_from: "2026-07-30T12:00:00Z", + }), + ], + }, + ); + const result = await pinned(await event(document, undefined, { attestation: true }), document); + expect(result.issuance_attestation.status).toBe("invalid"); + expect(result.errors).toContain("attestation_log_key_not_authorized_at_issuance"); + }); + + it("rejects private-network issuer origins before fetching metadata", async () => { + let fetched = false; + const resolver = createHttpsWebPkiResolver({ + allowedIssuerOrigins: ["https://127.0.0.1"], + fetch: async () => { + fetched = true; + return new Response(); + }, + }); + await expect( + resolver.resolve({ + issuer: "https://127.0.0.1", + metadataVersion: 1, + metadataHash: `sha256:${"0".repeat(64)}`, + }), + ).rejects.toThrow("issuer_origin_not_allowed"); + expect(fetched).toBe(false); + }); + + it("rejects issuer metadata redirects without following them", async () => { + const resolver = createHttpsWebPkiResolver({ + allowedIssuerOrigins: [ISSUER], + fetch: async () => + new Response(null, { + status: 302, + headers: { location: "https://attacker.example/metadata" }, + }), + }); + await expect( + resolver.resolve({ + issuer: ISSUER, + metadataVersion: 1, + metadataHash: `sha256:${"0".repeat(64)}`, + }), + ).rejects.toThrow("issuer_redirect_rejected"); + }); + + it("preserves historical metadata across metadata-key compromise and replacement", async () => { + const first = await metadata(); + const replacementMetadataJwk = jwk("metadata-2026-q4", LOG_PUBLIC); + const second = await signIssuerMetadata( + { + schema: "https://receiptprotocol.com/open-receipt/v0.2/issuer-metadata.schema.json", + issuer: ISSUER, + metadata_version: 2, + issued_at: "2026-07-31T12:00:00Z", + next_update: "2026-08-01T12:00:00Z", + previous_metadata_hash: await issuerMetadataHash(first), + keys: [ + metadataKey("open_receipt_evidence", evidenceJwk), + metadataKey("issuer_metadata", metadataJwk, { + status: "compromised", + compromised_at: "2026-07-31T00:00:00Z", + compromise_effective_from: "2026-07-31T00:00:00Z", + replaced_by_kid: replacementMetadataJwk.kid, + }), + metadataKey("issuer_metadata", replacementMetadataJwk, { + activated_at: "2026-07-31T00:00:00Z", + }), + ], + }, + await privateKey(LOG_SEED), + replacementMetadataJwk.kid, + ); + expect((await verifyIssuerMetadata(first)).valid).toBe(true); + expect((await verifyIssuerMetadata(second, first)).valid).toBe(true); + expect(second.keys.find((key) => key.kid === metadataJwk.kid)?.status).toBe("compromised"); + }); +});