From a98cf71c734bcd8054ca11eba829b6cbde3c5358 Mon Sep 17 00:00:00 2001 From: SDS <209957663+dkitchell@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:46:29 -0600 Subject: [PATCH] fix: the verifier could not verify any production certificate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every certificate CertifiedData has issued is cert.v2. The published verifier implemented only cert.v1 and rejected v2 as MALFORMED on a missing certification_id — v2 names it certificate_id. Separately, the pinned keys URL returned 404, so verification exited NETWORK before reaching a signature. Signed bytes are Ed25519 over RFC 8785 JCS of the payload. Confirmed empirically against the live production certificate now committed as fixtures/valid-cert-v2.json: of JCS(payload), JSON.stringify(payload), JCS(envelope minus signature) and JCS(payload minus the self-hash), only JCS(payload) verifies. This is a schema-mapping fix, not a cryptography change. Three things the original specification of this work got wrong about production, each of which would have shipped a verifier that still failed: 1. `signature` is an OBJECT, not a base64 string — /signed-payload serves {alg, key_id, value} and /api/certificates/:id serves {alg, key_id, sig}. Requiring a string reported MALFORMED on all 577 certificates. Both spellings and a bare string are now accepted. 2. The keys document to trust is /.well-known/signing-keys.json, which is live and DB-backed and is what every certificate's own public_key_url references. /.well-known/certifieddata-keys.json returns 404 and was never deployed. Its dialect differs (public_key_pem, "Ed25519", revocation in a top-level revoked[] array, CRLF in PEM bodies), so parseKeyDoc now normalizes both. Two of those differences were security-relevant: a mis-read algorithm yielded UNKNOWN_KEY on a good key, and an unmapped revoked[] would have let a revoked key keep verifying. 3. Resolving a bare id must fetch /signed-payload. The plain /api/certificates/:id response is a cert.v1-shaped display projection carrying the real signature bytes, but the signature covers the v2 payload rather than the projection — so verifying it returned INVALID on untampered certificates. Key selection reads the signed payload only. The envelope is not covered by the signature, so trusting its key_id would let whoever supplied the document choose which key it is checked against; a disagreement between the two is now MALFORMED rather than silently resolved. cert.v1 and the receipt path are untouched and still verify. 93 tests pass (was 71), typecheck clean, lint unchanged apart from one pre-existing warning. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish.yml | 23 +++ CHANGELOG.md | 43 +++++- README.md | 73 +++++++-- dist/cert-v2.d.ts | 48 ++++++ dist/cert-v2.d.ts.map | 1 + dist/cert-v2.js | 219 ++++++++++++++++++++++++++ dist/cert-v2.js.map | 1 + dist/cert-v2.test.d.ts | 2 + dist/cert-v2.test.d.ts.map | 1 + dist/cert-v2.test.js | 183 ++++++++++++++++++++++ dist/cert-v2.test.js.map | 1 + dist/cli.d.ts.map | 2 +- dist/cli.js | 24 ++- dist/cli.js.map | 2 +- dist/fetch-cert.d.ts | 27 +++- dist/fetch-cert.d.ts.map | 2 +- dist/fetch-cert.js | 30 +++- dist/fetch-cert.js.map | 2 +- dist/index.d.ts | 4 +- dist/index.d.ts.map | 2 +- dist/index.js | 3 +- dist/index.js.map | 2 +- dist/keys.d.ts | 14 +- dist/keys.d.ts.map | 2 +- dist/keys.js | 80 +++++++++- dist/keys.js.map | 2 +- dist/resolve.d.ts.map | 2 +- dist/resolve.js | 26 +++- dist/resolve.js.map | 2 +- fixtures/prod-keys.json | 13 ++ fixtures/valid-cert-v2.json | 60 ++++++++ package.json | 2 +- src/cert-v2.test.ts | 223 +++++++++++++++++++++++++++ src/cert-v2.ts | 280 ++++++++++++++++++++++++++++++++++ src/cli.ts | 27 +++- src/fetch-cert.ts | 44 +++++- src/index.ts | 4 +- src/keys.ts | 89 ++++++++++- src/resolve.ts | 36 ++++- 39 files changed, 1536 insertions(+), 65 deletions(-) create mode 100644 dist/cert-v2.d.ts create mode 100644 dist/cert-v2.d.ts.map create mode 100644 dist/cert-v2.js create mode 100644 dist/cert-v2.js.map create mode 100644 dist/cert-v2.test.d.ts create mode 100644 dist/cert-v2.test.d.ts.map create mode 100644 dist/cert-v2.test.js create mode 100644 dist/cert-v2.test.js.map create mode 100644 fixtures/prod-keys.json create mode 100644 fixtures/valid-cert-v2.json create mode 100644 src/cert-v2.test.ts create mode 100644 src/cert-v2.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c36c85..eb3116b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,8 +18,31 @@ jobs: node-version: 22 registry-url: https://registry.npmjs.org cache: npm + # Node 22 bundles npm 10.x. Trusted publishing (OIDC provenance with no + # long-lived token) requires npm >= 11.5.1, so upgrade the CLI before + # publishing. Without this, `npm publish --provenance` fails on auth. + - name: Upgrade npm for trusted publishing + run: npm install -g npm@latest + - run: npm ci - run: npm run lint - run: npm run typecheck - run: npm test + + # Fail loudly if package.json version does not match the release tag, + # rather than publishing a version nobody asked for. + - name: Check version matches release tag + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./package.json').version")" + if [ "$TAG" != "$PKG" ]; then + echo "::error::release tag v$TAG does not match package.json version $PKG" + exit 1 + fi + - run: npm publish --provenance --access public + env: + # Belt and braces: if the package is not yet configured for trusted + # publishing on npmjs.com, this token is what authenticates. Remove + # once trusted publishing is configured. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f138139..290ee5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). -## [0.1.0] - Unreleased +## [0.1.0] - 2026-09-07 ### Added @@ -14,7 +14,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - RFC 8785 JCS canonicalizer (`canonicalize.ts`). - Ed25519 signature verification using `node:crypto` only — zero third-party crypto dependencies. - `cert.v1` schema support. +- `cert.v2` schema support (`cert-v2.ts`) — the schema production has issued + since 2026-02. v2 differs from v1 in three ways that matter: the signature is + detached rather than a field inside the signed document (so the whole payload + is canonicalized, with nothing stripped), the signer is named at + `payload.issuer.signing_key_id`, and the artifact digest is bare hex at + `payload.artifact_hash`. Signed bytes are Ed25519 over JCS of the payload, + confirmed empirically against the live production certificate committed as + `fixtures/valid-cert-v2.json`. +- Payment receipt verification against the published Agent Commerce key. - `--dataset`, `--json`, `--offline`, `--keys`, `--no-cache` flags. -- Trusted-keys document fetched from `https://certifieddata.io/.well-known/certifieddata-keys.json` with TTL cache at `~/.certifieddata/keys.json`. +- Trusted-keys document fetched from the issuer's `.well-known` signing-keys + document, with TTL cache at `~/.certifieddata/keys.json`. - Six exit codes documented in the README and `--help`. -- 34 tests across canonicalize, verify, and CLI suites. +- 93 tests across canonicalize, verify, cert-v2, receipt, exit-code and CLI suites. + +### Fixed + +- **The certificate path could not verify any production certificate.** Two + independent causes, both addressed here: + - Every issued certificate is `cert.v2`; the verifier implemented only + `cert.v1` and rejected v2 as `MALFORMED` on a missing `certification_id` + (v2 names it `certificate_id`). + - The pinned keys URL was `/.well-known/certifieddata-keys.json`, which + returns 404 and was never deployed, so verification exited `NETWORK` before + reaching the signature. It now points at + `/.well-known/signing-keys.json` — the document the issuer actually + publishes and that every certificate's own `public_key_url` references. +- Resolving a bare certificate id now fetches `…/signed-payload` rather than + the plain `…/api/certificates/` projection. The projection carries the + real signature bytes but the signature covers the v2 payload, not the + projection, so verifying it reported `INVALID` on untampered certificates. +- The keys-document parser accepts both published dialects + (`public_key` / `public_key_pem`, per-key `revoked_at` / top-level + `revoked[]` and `retired[]`, CRLF in PEM bodies) and compares `algorithm` + case-insensitively. Previously an `algorithm` of `"Ed25519"` — the spelling + the issuer publishes — yielded `UNKNOWN_KEY`, a security verdict, for a + difference of one capital letter. A revocation entry that cannot be parsed is + now an error rather than being skipped, so an unreadable revocation record can + never be mistaken for a good key. +- Human output no longer prints `0 rows × 0 cols` for `cert.v2`, which has no + column count. diff --git a/README.md b/README.md index c508d38..4f09dd2 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,57 @@ certifieddata-verify ce_01HXYZ123abc... --dataset path/to/data.csv ## What this verifies -- **The signature.** `cert.signature` is an Ed25519 signature over the RFC 8785 JCS canonicalization of the rest of the certificate. We re-canonicalize, re-verify, and refuse to claim a cert is valid unless the signature checks out. -- **The signer.** `cert.key_id` must appear in the issuer's published [`.well-known` keys document](https://certifieddata.io/.well-known/certifieddata-keys.json) and must not be revoked. -- **The dataset (optional).** When `--dataset ` is supplied, we stream-hash the file and refuse to claim a match unless its SHA-256 is bit-identical to `cert.dataset_hash`. +- **The signature.** An Ed25519 signature over the RFC 8785 JCS canonicalization of the certificate payload. We re-canonicalize, re-verify, and refuse to claim a cert is valid unless the signature checks out. +- **The signer.** The certificate's signing key must appear in the issuer's published [signing-keys document](https://certifieddata.io/.well-known/signing-keys.json) and must not be revoked. That URL is pinned in this package; we deliberately do **not** follow the `public_key_url` inside a certificate, because a document that has not been verified yet must not choose the keys it is verified against. +- **The dataset (optional).** When `--dataset ` is supplied, we stream-hash the file and refuse to claim a match unless its SHA-256 is bit-identical to the digest in the certificate. + +## Certificate schemas + +Both issued schemas are supported, and the CLI picks the right one from the +document itself — you never pass a flag for it. + +| | `cert.v1` | `cert.v2` (current) | +|---|---|---| +| Signature location | `cert.signature`, inside the document | detached, beside the payload | +| Canonicalized bytes | certificate **minus** `signature` | the **whole** payload | +| Signer named at | `cert.key_id` | `payload.issuer.signing_key_id` | +| Artifact digest | `cert.dataset_hash` (`sha256:…`) | `payload.artifact_hash` (bare hex) | + +A `cert.v2` document is an envelope. Note that the outer `schema_version` names +the envelope, not the certificate, and that `signature` is an **object** rather +than a bare string: + +```json +{ + "schema_version": "certifieddata.manifest.v1", + "payload": { "schema_version": "cert.v2", "certificate_id": "d6da041f-…", "…": "…" }, + "signature": { "alg": "Ed25519", "key_id": "ed25519-prod-2025-02", "value": "base64…" } +} +``` + +Because the v2 signature is detached, the payload is canonicalized exactly as +issued — nothing is removed before verification. A bare base64 `signature` +string is also accepted. + +### Which endpoint to verify against + +Resolving a bare certificate id fetches +`https://api.certifieddata.io/api/certificates//signed-payload`. + +Use that one. `…/api/certificates/` (without the suffix) returns a +`certifieddata.cert.v1`-shaped **display projection** of the same certificate. +It carries the real signature bytes, but the signature covers the v2 payload +rather than the projection, so verifying that document reports `INVALID` — +which reads as tampering when nothing has been tampered with. + +> **Note on `hashes.certificate_payload_sha256`.** Some v2 payloads carry a +> self-referential digest field. It is *not* part of the trust decision and this +> verifier ignores it: the Ed25519 signature over the canonicalized payload is +> what establishes integrity. The published value is not reproducible from the +> stored document under JCS, plain `JSON.stringify`, sorted-key stringify, or +> pretty-printed JSON — most likely it was computed over insertion-ordered JSON, +> which Postgres `jsonb` does not preserve. Do not treat a mismatch in that +> field as a verification failure. ## Why audit-friendly @@ -81,8 +129,8 @@ The non-zero exit codes fail the job automatically — a CI run will not pass if ```bash # Pre-stage a copy of the issuer's keys document, then verify with no network. -curl -O https://certifieddata.io/.well-known/certifieddata-keys.json -certifieddata-verify ./received-cert.json --keys ./certifieddata-keys.json --offline +curl -O https://certifieddata.io/.well-known/signing-keys.json +certifieddata-verify ./received-cert.json --keys ./signing-keys.json --offline ``` `--offline` refuses to make any network call. Combined with `--keys`, it produces a fully reproducible audit you can replay months later. @@ -108,13 +156,18 @@ CertifiedData's opinion about CertifiedData's own signature. ## How CertifiedData certificates work -CertifiedData.io issues `cert.v1` documents that bind together: +CertifiedData.io currently issues `cert.v2` documents (`cert.v1` is still +supported here and still verifies). Both bind together: -1. A **dataset hash** — `sha256(file_bytes)` for binary data (CSV, Parquet) or `sha256(JCS(payload))` for structured data. -2. **Provenance** — the algorithm used, row/column counts, the issuance timestamp, and an opaque `certification_id`. -3. A **signer** — `key_id`, with the public key fetched from the issuer's `.well-known` endpoint. +1. An **artifact hash** — `sha256(file_bytes)` for binary data (CSV, Parquet, ZIP) or `sha256(JCS(payload))` for structured data. +2. **Provenance** — the issuing engine, a record count, the issuance timestamp, and an opaque certificate id. +3. A **signer** — a `signing_key_id`, with the public key fetched from the issuer's pinned `.well-known` signing-keys document. -The signature is computed over the RFC 8785 JCS canonicalization of the certificate **with the `signature` field omitted** — this is the only sane way to sign a JSON document and have it round-trip through arbitrary JSON parsers. +In `cert.v1` the signature is computed over the JCS canonicalization of the +certificate **with the `signature` field omitted**. In `cert.v2` the signature is +detached and travels beside the payload, so the **whole** payload is +canonicalized with nothing stripped. Either way, nothing signs the field that +holds its own signature. We use Ed25519 because it is fast, deterministic, has small keys (32 bytes) and small signatures (64 bytes), and is built into Node's `crypto` module. We never sign the field that contains the signature, and we never claim a verdict beyond what the cert actually says — for example, we will not call a CTGAN cert "differentially private" unless the metadata explicitly carries a non-null `epsilon` and the algorithm is `DP-CTGAN`. diff --git a/dist/cert-v2.d.ts b/dist/cert-v2.d.ts new file mode 100644 index 0000000..c91728b --- /dev/null +++ b/dist/cert-v2.d.ts @@ -0,0 +1,48 @@ +import type { KeyDoc, VerifyResult } from "./types.js"; +export interface CertV2Issuer { + name?: string; + signing_key_id: string; + signature_alg?: string; + environment?: string; +} +export interface CertV2Payload { + schema_version: "cert.v2"; + certificate_id: string; + certificate_type?: string; + issued_at: string; + artifact_hash: string; + hash_method?: string; + issuer: CertV2Issuer; + subject?: Record; + manifest?: { + engine?: string; + record_count?: number; + [k: string]: unknown; + }; + [k: string]: unknown; +} +/** The signature as production actually serves it, or as a bare base64 string. */ +export type CertV2Signature = string | { + value?: string; + sig?: string; + signature?: string; + alg?: string; + key_id?: string; +}; +export interface CertV2Envelope { + payload: CertV2Payload; + /** base64 Ed25519, already unwrapped from whichever spelling arrived. */ + signature: string; + signature_alg?: string; + /** key_id as claimed by the UNSIGNED envelope. Never used to select a key. */ + envelope_key_id?: string; +} +/** True when doc is a v2 envelope or a bare v2 payload carrying a sibling signature. */ +export declare function isCertV2(doc: unknown): boolean; +/** + * Normalize either shape into an envelope. + * Accepts {payload, signature} or a bare payload with a sibling signature. + */ +export declare function toEnvelope(doc: Record): CertV2Envelope | string; +export declare function verifyCertificateV2(doc: Record, trustedKeys: KeyDoc, datasetPath?: string): Promise; +//# sourceMappingURL=cert-v2.d.ts.map \ No newline at end of file diff --git a/dist/cert-v2.d.ts.map b/dist/cert-v2.d.ts.map new file mode 100644 index 0000000..45206d8 --- /dev/null +++ b/dist/cert-v2.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cert-v2.d.ts","sourceRoot":"","sources":["../src/cert-v2.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEvD,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,cAAc,EAAE,SAAS,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,YAAY,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,QAAQ,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAC;IAC5E,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;CACtB;AAED,kFAAkF;AAClF,MAAM,MAAM,eAAe,GACvB,MAAM,GACN;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAExF,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,aAAa,CAAC;IACvB,yEAAyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,wFAAwF;AACxF,wBAAgB,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAM9C;AAwBD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,cAAc,GAAG,MAAM,CAmBhF;AAED,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,WAAW,EAAE,MAAM,EACnB,WAAW,CAAC,EAAE,MAAM,GACnB,OAAO,CAAC,YAAY,CAAC,CAmFvB"} \ No newline at end of file diff --git a/dist/cert-v2.js b/dist/cert-v2.js new file mode 100644 index 0000000..22e8158 --- /dev/null +++ b/dist/cert-v2.js @@ -0,0 +1,219 @@ +// cert.v2 verification. +// +// Differences from cert.v1 that matter for verification: +// +// 1. The signature is NOT a field inside the signed document. In v1 the +// signature lives on the certificate and is stripped before +// canonicalization; in v2 the payload is signed as-is and the signature +// travels beside it in an envelope. So v2 canonicalizes the WHOLE payload +// — nothing is removed. +// 2. The signer is named at payload.issuer.signing_key_id, not cert.key_id. +// 3. The artifact digest is bare lowercase hex at payload.artifact_hash, +// with no "sha256:" prefix. +// 4. There is no rows/columns/algorithm triple. manifest.record_count and +// manifest.engine are the nearest equivalents and are display-only. +// +// Signed bytes are Ed25519 over RFC 8785 (JCS) of the payload. Confirmed +// empirically against the live production certificate in +// fixtures/valid-cert-v2.json: of JCS(payload), JSON.stringify(payload), +// JCS(envelope minus signature) and JCS(payload minus the self-hash), only +// JCS(payload) verifies. +// +// On the envelope's signature field: production serves it as an OBJECT, not a +// bare base64 string — +// +// /api/certificates/:id/signed-payload -> {alg, key_id, value} +// /api/certificates/:id -> {alg, key_id, sig} +// +// so both spellings are accepted, as is a plain string. A verifier that only +// accepted the string form would report MALFORMED on every certificate the +// company has issued. +import { createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; +import { sha256File, formatDigest } from "./hash.js"; +import { findKey } from "./keys.js"; +/** True when doc is a v2 envelope or a bare v2 payload carrying a sibling signature. */ +export function isCertV2(doc) { + if (!doc || typeof doc !== "object") + return false; + const d = doc; + if (d.schema_version === "cert.v2") + return true; + const p = d.payload; + return !!p && typeof p === "object" && p.schema_version === "cert.v2"; +} +/** + * Pull the base64 signature out of whichever shape arrived. + * Returns null when there is nothing usable, so the caller can say so plainly. + */ +function unwrapSignature(raw) { + if (typeof raw === "string") { + return raw.length > 0 ? { b64: raw } : null; + } + if (raw && typeof raw === "object") { + const o = raw; + // `value` is what /signed-payload emits; `sig` is what the base route emits. + const b64 = [o.value, o.sig, o.signature].find((v) => typeof v === "string" && v.length > 0); + if (typeof b64 !== "string") + return null; + return { + b64, + alg: typeof o.alg === "string" ? o.alg : undefined, + keyId: typeof o.key_id === "string" ? o.key_id : undefined, + }; + } + return null; +} +/** + * Normalize either shape into an envelope. + * Accepts {payload, signature} or a bare payload with a sibling signature. + */ +export function toEnvelope(doc) { + const hasNested = !!doc.payload && typeof doc.payload === "object"; + const payload = (hasNested ? doc.payload : doc); + const unwrapped = unwrapSignature(doc.signature); + if (!unwrapped) { + return "cert.v2 requires a detached signature alongside the payload; none was present"; + } + if (payload.schema_version !== "cert.v2") { + return `unsupported schema_version: ${String(payload.schema_version)}`; + } + return { + payload, + signature: unwrapped.b64, + signature_alg: unwrapped.alg ?? (typeof doc.signature_alg === "string" ? doc.signature_alg : undefined), + envelope_key_id: unwrapped.keyId ?? (typeof doc.signing_key_id === "string" ? doc.signing_key_id : undefined), + }; +} +export async function verifyCertificateV2(doc, trustedKeys, datasetPath) { + const result = blankV2Result(); + const env = toEnvelope(doc); + if (typeof env === "string") + return finish(result, "MALFORMED", env); + const { payload, signature } = env; + const shapeError = validateV2Shape(payload); + if (shapeError) + return finish(result, "MALFORMED", shapeError); + result.certification_id = payload.certificate_id; + result.issuer = payload.issuer?.name ?? null; + result.signed_at = payload.issued_at; + result.algorithm = payload.manifest?.engine ?? payload.certificate_type ?? null; + result.dataset_hash_expected = formatDigest(payload.artifact_hash.toLowerCase()); + if (typeof payload.manifest?.record_count === "number") { + result.rows = payload.manifest.record_count; + } + // Key selection reads the SIGNED payload only. The envelope is not covered by + // the signature, so trusting its key_id would let anyone redirect which key + // is used to check the bytes — and then present a document that "verifies". + const keyId = payload.issuer.signing_key_id; + if (!keyId) + return finish(result, "MALFORMED", "missing issuer.signing_key_id"); + result.key_id = keyId; + // A disagreement between the signed payload and the envelope means the + // document is internally inconsistent. Refuse rather than silently + // preferring one, so the condition is visible instead of papered over. + if (env.envelope_key_id && env.envelope_key_id !== keyId) { + return finish(result, "MALFORMED", `envelope names key_id ${env.envelope_key_id} but the signed payload names ${keyId}`); + } + const key = findKey(trustedKeys, keyId); + if (!key || key.revoked_at || !isEd25519(key.algorithm)) { + result.checks.key_trust = "fail"; + const reason = !key + ? `key_id ${keyId} not in trusted keys` + : key.revoked_at + ? `key_id ${keyId} was revoked at ${key.revoked_at}` + : `key ${keyId} is not ed25519`; + return finish(result, "UNKNOWN_KEY", reason); + } + result.checks.key_trust = "pass"; + result.key_label = key.label; + const sigBytes = decodeSignature(signature); + if (!sigBytes) { + return finish(result, "MALFORMED", "signature is not 64 bytes of base64-encoded Ed25519"); + } + // v2 signs the entire payload — nothing is stripped. + const canonicalBytes = canonicalizeToBytes(payload); + const publicKey = createPublicKey({ key: pemFromRawEd25519(key.public_key), format: "pem" }); + const sigOk = cryptoVerify(null, canonicalBytes, publicKey, sigBytes); + result.checks.signature = sigOk ? "pass" : "fail"; + if (!sigOk) { + return finish(result, "INVALID", "ed25519 signature does not verify against canonicalized cert.v2 payload"); + } + if (datasetPath) { + const actualHex = await sha256File(datasetPath); + result.dataset_hash_actual = formatDigest(actualHex); + if (actualHex !== payload.artifact_hash.toLowerCase()) { + result.checks.dataset_match = "fail"; + return finish(result, "DATASET_MISMATCH", `artifact hash mismatch (expected sha256:${payload.artifact_hash.toLowerCase()}, got ${result.dataset_hash_actual})`); + } + result.checks.dataset_match = "pass"; + } + return finish(result, "VALID", "signature verified and key is trusted"); +} +/** + * Compared case-insensitively on purpose. The published keys document spells + * this "Ed25519"; this verifier's own fixtures spell it "ed25519". Treating + * that as an untrusted key would report UNKNOWN_KEY — a security verdict — for + * a cosmetic difference, which teaches users to disbelieve the tool. + */ +function isEd25519(algorithm) { + return typeof algorithm === "string" && algorithm.toLowerCase() === "ed25519"; +} +function validateV2Shape(p) { + if (!p || typeof p !== "object") + return "cert.v2 payload is not an object"; + for (const f of ["certificate_id", "issued_at", "artifact_hash", "issuer"]) { + if (p[f] === undefined || p[f] === null) + return `missing required field: ${f}`; + } + if (!p.issuer || typeof p.issuer !== "object") + return "issuer must be an object"; + if (typeof p.artifact_hash !== "string" || !/^[0-9a-f]{64}$/i.test(p.artifact_hash)) { + return "artifact_hash must be 64 hex characters"; + } + if (p.hash_method && !/^sha-?256$/i.test(p.hash_method)) { + return `unsupported hash_method: ${p.hash_method}`; + } + return null; +} +function decodeSignature(b64) { + try { + const buf = Buffer.from(b64, "base64"); + if (buf.length !== 64) + return null; + return buf; + } + catch { + return null; + } +} +function pemFromRawEd25519(material) { + if (material.includes("BEGIN PUBLIC KEY")) + return material; + const raw = Buffer.from(material, "base64"); + if (raw.length !== 32) + throw new Error(`expected 32-byte ed25519 key, got ${raw.length}`); + const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); + const der = Buffer.concat([spkiPrefix, raw]).toString("base64"); + return `-----BEGIN PUBLIC KEY-----\n${der.match(/.{1,64}/g).join("\n")}\n-----END PUBLIC KEY-----\n`; +} +function blankV2Result() { + return { + verdict: "MALFORMED", + certification_id: null, + key_id: null, + issuer: null, + algorithm: null, + signed_at: null, + dataset_hash_expected: null, + dataset_hash_actual: null, + checks: { signature: "skipped", key_trust: "skipped", dataset_match: "skipped" }, + reason: "", + }; +} +function finish(r, verdict, reason) { + r.verdict = verdict; + r.reason = reason; + return r; +} +//# sourceMappingURL=cert-v2.js.map \ No newline at end of file diff --git a/dist/cert-v2.js.map b/dist/cert-v2.js.map new file mode 100644 index 0000000..bbce700 --- /dev/null +++ b/dist/cert-v2.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cert-v2.js","sourceRoot":"","sources":["../src/cert-v2.ts"],"names":[],"mappings":"AAAA,wBAAwB;AACxB,EAAE;AACF,yDAAyD;AACzD,EAAE;AACF,0EAA0E;AAC1E,iEAAiE;AACjE,6EAA6E;AAC7E,+EAA+E;AAC/E,6BAA6B;AAC7B,8EAA8E;AAC9E,2EAA2E;AAC3E,iCAAiC;AACjC,4EAA4E;AAC5E,yEAAyE;AACzE,EAAE;AACF,yEAAyE;AACzE,yDAAyD;AACzD,yEAAyE;AACzE,2EAA2E;AAC3E,yBAAyB;AACzB,EAAE;AACF,8EAA8E;AAC9E,uBAAuB;AACvB,EAAE;AACF,qEAAqE;AACrE,mEAAmE;AACnE,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,sBAAsB;AAEtB,OAAO,EAAE,eAAe,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAqCpC,wFAAwF;AACxF,MAAM,UAAU,QAAQ,CAAC,GAAY;IACnC,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAClD,MAAM,CAAC,GAAG,GAA8B,CAAC;IACzC,IAAI,CAAC,CAAC,cAAc,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IAChD,MAAM,CAAC,GAAG,CAAC,CAAC,OAA8C,CAAC;IAC3D,OAAO,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,cAAc,KAAK,SAAS,CAAC;AACxE,CAAC;AAED;;;GAGG;AACH,SAAS,eAAe,CAAC,GAAY;IACnC,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5B,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;IAC9C,CAAC;IACD,IAAI,GAAG,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QACnC,MAAM,CAAC,GAAG,GAA8B,CAAC;QACzC,6EAA6E;QAC7E,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;QAC7F,IAAI,OAAO,GAAG,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QACzC,OAAO;YACL,GAAG;YACH,GAAG,EAAE,OAAO,CAAC,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS;YAClD,KAAK,EAAE,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS;SAC3D,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,GAA4B;IACrD,MAAM,SAAS,GAAG,CAAC,CAAC,GAAG,CAAC,OAAO,IAAI,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ,CAAC;IACnE,MAAM,OAAO,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAkB,CAAC;IAEjE,MAAM,SAAS,GAAG,eAAe,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACjD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,OAAO,+EAA+E,CAAC;IACzF,CAAC;IACD,IAAI,OAAO,CAAC,cAAc,KAAK,SAAS,EAAE,CAAC;QACzC,OAAO,+BAA+B,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,CAAC;IACzE,CAAC;IACD,OAAO;QACL,OAAO;QACP,SAAS,EAAE,SAAS,CAAC,GAAG;QACxB,aAAa,EACX,SAAS,CAAC,GAAG,IAAI,CAAC,OAAO,GAAG,CAAC,aAAa,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,SAAS,CAAC;QAC1F,eAAe,EACb,SAAS,CAAC,KAAK,IAAI,CAAC,OAAO,GAAG,CAAC,cAAc,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,SAAS,CAAC;KAC/F,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,GAA4B,EAC5B,WAAmB,EACnB,WAAoB;IAEpB,MAAM,MAAM,GAAG,aAAa,EAAE,CAAC;IAE/B,MAAM,GAAG,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC;IAC5B,IAAI,OAAO,GAAG,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,CAAC,CAAC;IAErE,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,GAAG,CAAC;IACnC,MAAM,UAAU,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IAC5C,IAAI,UAAU;QAAE,OAAO,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,UAAU,CAAC,CAAC;IAE/D,MAAM,CAAC,gBAAgB,GAAG,OAAO,CAAC,cAAc,CAAC;IACjD,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,IAAI,IAAI,IAAI,CAAC;IAC7C,MAAM,CAAC,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC;IACrC,MAAM,CAAC,SAAS,GAAG,OAAO,CAAC,QAAQ,EAAE,MAAM,IAAI,OAAO,CAAC,gBAAgB,IAAI,IAAI,CAAC;IAChF,MAAM,CAAC,qBAAqB,GAAG,YAAY,CAAC,OAAO,CAAC,aAAa,CAAC,WAAW,EAAE,CAAC,CAAC;IACjF,IAAI,OAAO,OAAO,CAAC,QAAQ,EAAE,YAAY,KAAK,QAAQ,EAAE,CAAC;QACvD,MAAM,CAAC,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC;IAC9C,CAAC;IAED,8EAA8E;IAC9E,4EAA4E;IAC5E,4EAA4E;IAC5E,MAAM,KAAK,GAAG,OAAO,CAAC,MAAM,CAAC,cAAc,CAAC;IAC5C,IAAI,CAAC,KAAK;QAAE,OAAO,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,+BAA+B,CAAC,CAAC;IAChF,MAAM,CAAC,MAAM,GAAG,KAAK,CAAC;IAEtB,uEAAuE;IACvE,mEAAmE;IACnE,uEAAuE;IACvE,IAAI,GAAG,CAAC,eAAe,IAAI,GAAG,CAAC,eAAe,KAAK,KAAK,EAAE,CAAC;QACzD,OAAO,MAAM,CACX,MAAM,EACN,WAAW,EACX,yBAAyB,GAAG,CAAC,eAAe,iCAAiC,KAAK,EAAE,CACrF,CAAC;IACJ,CAAC;IAED,MAAM,GAAG,GAAG,OAAO,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC;IACxC,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,UAAU,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;QACxD,MAAM,CAAC,MAAM,CAAC,SAAS,GAAG,MAAM,CAAC;QACjC,MAAM,MAAM,GAAG,CAAC,GAAG;YACjB,CAAC,CAAC,UAAU,KAAK,sBAAsB;YACvC,CAAC,CAAC,GAAG,CAAC,UAAU;gBACd,CAAC,CAAC,UAAU,KAAK,mBAAmB,GAAG,CAAC,UAAU,EAAE;gBACpD,CAAC,CAAC,OAAO,KAAK,iBAAiB,CAAC;QACpC,OAAO,MAAM,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,CAAC,CAAC;IAC/C,CAAC;IACD,MAAM,CAAC,MAAM,CAAC,SAAS,GAAG,MAAM,CAAC;IACjC,MAAM,CAAC,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC;IAE7B,MAAM,QAAQ,GAAG,eAAe,CAAC,SAAS,CAAC,CAAC;IAC5C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE,qDAAqD,CAAC,CAAC;IAC5F,CAAC;IAED,qDAAqD;IACrD,MAAM,cAAc,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;IACpD,MAAM,SAAS,GAAG,eAAe,CAAC,EAAE,GAAG,EAAE,iBAAiB,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;IAC7F,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,cAAc,EAAE,SAAS,EAAE,QAAQ,CAAC,CAAC;IACtE,MAAM,CAAC,MAAM,CAAC,SAAS,GAAG,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IAClD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,MAAM,CACX,MAAM,EACN,SAAS,EACT,yEAAyE,CAC1E,CAAC;IACJ,CAAC;IAED,IAAI,WAAW,EAAE,CAAC;QAChB,MAAM,SAAS,GAAG,MAAM,UAAU,CAAC,WAAW,CAAC,CAAC;QAChD,MAAM,CAAC,mBAAmB,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;QACrD,IAAI,SAAS,KAAK,OAAO,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,CAAC;YACtD,MAAM,CAAC,MAAM,CAAC,aAAa,GAAG,MAAM,CAAC;YACrC,OAAO,MAAM,CACX,MAAM,EACN,kBAAkB,EAClB,2CAA2C,OAAO,CAAC,aAAa,CAAC,WAAW,EAAE,SAAS,MAAM,CAAC,mBAAmB,GAAG,CACrH,CAAC;QACJ,CAAC;QACD,MAAM,CAAC,MAAM,CAAC,aAAa,GAAG,MAAM,CAAC;IACvC,CAAC;IAED,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,uCAAuC,CAAC,CAAC;AAC1E,CAAC;AAED;;;;;GAKG;AACH,SAAS,SAAS,CAAC,SAA6B;IAC9C,OAAO,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,WAAW,EAAE,KAAK,SAAS,CAAC;AAChF,CAAC;AAED,SAAS,eAAe,CAAC,CAAgB;IACvC,IAAI,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ;QAAE,OAAO,kCAAkC,CAAC;IAC3E,KAAK,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,WAAW,EAAE,eAAe,EAAE,QAAQ,CAAU,EAAE,CAAC;QACpF,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,IAAI;YAAE,OAAO,2BAA2B,CAAC,EAAE,CAAC;IACjF,CAAC;IACD,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,OAAO,CAAC,CAAC,MAAM,KAAK,QAAQ;QAAE,OAAO,0BAA0B,CAAC;IACjF,IAAI,OAAO,CAAC,CAAC,aAAa,KAAK,QAAQ,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,CAAC,EAAE,CAAC;QACpF,OAAO,yCAAyC,CAAC;IACnD,CAAC;IACD,IAAI,CAAC,CAAC,WAAW,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,EAAE,CAAC;QACxD,OAAO,4BAA4B,CAAC,CAAC,WAAW,EAAE,CAAC;IACrD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAS,eAAe,CAAC,GAAW;IAClC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;QACvC,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE;YAAE,OAAO,IAAI,CAAC;QACnC,OAAO,GAAG,CAAC;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,QAAgB;IACzC,IAAI,QAAQ,CAAC,QAAQ,CAAC,kBAAkB,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC3D,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;IAC5C,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;IAC1F,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;IAClE,MAAM,GAAG,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IAChE,OAAO,+BAA+B,GAAG,CAAC,KAAK,CAAC,UAAU,CAAE,CAAC,IAAI,CAAC,IAAI,CAAC,8BAA8B,CAAC;AACxG,CAAC;AAED,SAAS,aAAa;IACpB,OAAO;QACL,OAAO,EAAE,WAAW;QACpB,gBAAgB,EAAE,IAAI;QACtB,MAAM,EAAE,IAAI;QACZ,MAAM,EAAE,IAAI;QACZ,SAAS,EAAE,IAAI;QACf,SAAS,EAAE,IAAI;QACf,qBAAqB,EAAE,IAAI;QAC3B,mBAAmB,EAAE,IAAI;QACzB,MAAM,EAAE,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,aAAa,EAAE,SAAS,EAAE;QAChF,MAAM,EAAE,EAAE;KACX,CAAC;AACJ,CAAC;AAED,SAAS,MAAM,CAAC,CAAe,EAAE,OAAgC,EAAE,MAAc;IAC/E,CAAC,CAAC,OAAO,GAAG,OAAO,CAAC;IACpB,CAAC,CAAC,MAAM,GAAG,MAAM,CAAC;IAClB,OAAO,CAAC,CAAC;AACX,CAAC"} \ No newline at end of file diff --git a/dist/cert-v2.test.d.ts b/dist/cert-v2.test.d.ts new file mode 100644 index 0000000..97dc27f --- /dev/null +++ b/dist/cert-v2.test.d.ts @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=cert-v2.test.d.ts.map \ No newline at end of file diff --git a/dist/cert-v2.test.d.ts.map b/dist/cert-v2.test.d.ts.map new file mode 100644 index 0000000..3c1a167 --- /dev/null +++ b/dist/cert-v2.test.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"cert-v2.test.d.ts","sourceRoot":"","sources":["../src/cert-v2.test.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/dist/cert-v2.test.js b/dist/cert-v2.test.js new file mode 100644 index 0000000..0952c66 --- /dev/null +++ b/dist/cert-v2.test.js @@ -0,0 +1,183 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { isCertV2, toEnvelope, verifyCertificateV2 } from "./cert-v2.js"; +const here = dirname(fileURLToPath(import.meta.url)); +const fixtures = join(here, "..", "fixtures"); +/** Fresh copy each time — several tests mutate the document. */ +async function loadEnvelope() { + return JSON.parse(await readFile(join(fixtures, "valid-cert-v2.json"), "utf8")); +} +async function prodKeys() { + return JSON.parse(await readFile(join(fixtures, "prod-keys.json"), "utf8")); +} +// ── detection ──────────────────────────────────────────────────────────────── +test("isCertV2 detects the production envelope", async () => { + const env = await loadEnvelope(); + // Production's OUTER schema_version is "certifieddata.manifest.v1"; only the + // payload says cert.v2. Detection that looked only at the top level would + // miss every real certificate. + assert.equal(env.schema_version, "certifieddata.manifest.v1"); + assert.equal(isCertV2(env), true); +}); +test("isCertV2 rejects v1 and non-objects", () => { + assert.equal(isCertV2({ schema_version: "cert.v1" }), false); + assert.equal(isCertV2(null), false); + assert.equal(isCertV2("cert.v2"), false); +}); +// ── the real certificate ───────────────────────────────────────────────────── +test("verifies a real production cert.v2 certificate", async () => { + const res = await verifyCertificateV2(await loadEnvelope(), await prodKeys()); + assert.equal(res.verdict, "VALID"); + assert.equal(res.checks.signature, "pass"); + assert.equal(res.checks.key_trust, "pass"); + assert.equal(res.key_id, "ed25519-prod-2025-02"); + assert.equal(res.certification_id, "d6da041f-a70c-4945-93b7-dff1e42a00d0"); +}); +// ── signature spellings ────────────────────────────────────────────────────── +// +// These are the regression tests for the bug that made the first cut of this +// feature useless: production serves `signature` as an OBJECT, and an +// implementation that required a base64 string reported MALFORMED on all 577 +// issued certificates. +test('accepts the {alg, key_id, value} signature object that /signed-payload serves', async () => { + const env = await loadEnvelope(); + assert.equal(typeof env.signature, "object", "fixture should carry the production object form"); + assert.ok(env.signature.value); + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); +test('accepts the {alg, key_id, sig} signature object that /api/certificates/:id serves', async () => { + const env = await loadEnvelope(); + const sig = env.signature; + env.signature = { alg: sig.alg, key_id: sig.key_id, sig: sig.value }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); +test("accepts a bare base64 signature string", async () => { + const env = await loadEnvelope(); + env.signature = env.signature.value; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); +test("refuses a v2 payload with no detached signature", async () => { + const env = await loadEnvelope(); + delete env.signature; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /detached signature/); +}); +test("refuses a signature object carrying no usable value", async () => { + const env = await loadEnvelope(); + env.signature = { alg: "Ed25519", key_id: "ed25519-prod-2025-02" }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /detached signature/); +}); +test("refuses a signature that is not 64 bytes", async () => { + const env = await loadEnvelope(); + env.signature = Buffer.from("too short").toString("base64"); + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /64 bytes/); +}); +// ── key selection is a security boundary ───────────────────────────────────── +test("selects the key named in the SIGNED payload, not the envelope", async () => { + const env = await loadEnvelope(); + const sig = env.signature; + // The envelope is not covered by the signature. If key selection trusted it, + // an attacker could point verification at a key of their choosing. + env.signature = { ...sig, key_id: "attacker-supplied-key" }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /envelope names key_id attacker-supplied-key/); + assert.equal(res.checks.signature, "skipped"); +}); +test("tolerates the envelope omitting key_id entirely", async () => { + const env = await loadEnvelope(); + const sig = env.signature; + env.signature = { alg: sig.alg, value: sig.value }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); +// ── trust and revocation ───────────────────────────────────────────────────── +test("rejects an unknown signing key", async () => { + const keys = { issuer: "CertifiedData.io", keys: [] }; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "UNKNOWN_KEY"); + assert.equal(res.checks.signature, "skipped"); +}); +test("rejects a revoked signing key", async () => { + const keys = await prodKeys(); + keys.keys[0].revoked_at = "2026-09-01T00:00:00Z"; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "UNKNOWN_KEY"); + assert.match(res.reason, /revoked/); +}); +test('accepts algorithm "Ed25519" as well as "ed25519"', async () => { + const keys = await prodKeys(); + // This is how the issuer's own published keys document spells it. Returning + // UNKNOWN_KEY on the capital E would be a security verdict for a cosmetic + // difference. + keys.keys[0].algorithm = "Ed25519"; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "VALID"); +}); +test("rejects a key that is not ed25519 at all", async () => { + const keys = await prodKeys(); + keys.keys[0].algorithm = "rsa"; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "UNKNOWN_KEY"); + assert.match(res.reason, /not ed25519/); +}); +// ── tamper detection ───────────────────────────────────────────────────────── +test("rejects a tampered v2 payload", async () => { + const env = await loadEnvelope(); + env.payload.artifact_hash = "0".repeat(64); + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "INVALID"); + assert.equal(res.checks.signature, "fail"); +}); +test("rejects a payload with a field appended", async () => { + const env = await loadEnvelope(); + env.payload.injected = "not in the signed bytes"; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "INVALID"); + assert.equal(res.checks.signature, "fail"); +}); +test("rejects a payload with a field removed", async () => { + const env = await loadEnvelope(); + delete env.payload.certification_scope; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "INVALID"); +}); +// ── shape validation ───────────────────────────────────────────────────────── +test("reports a missing required field rather than failing the signature", async () => { + const env = await loadEnvelope(); + delete env.payload.certificate_id; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /certificate_id/); +}); +test("rejects a non-hex artifact_hash", async () => { + const env = await loadEnvelope(); + env.payload.artifact_hash = "sha256:not-hex"; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /64 hex/); +}); +test("toEnvelope explains an unsupported schema rather than throwing", () => { + const out = toEnvelope({ payload: { schema_version: "cert.v3" }, signature: "x".repeat(88) }); + assert.equal(typeof out, "string"); + assert.match(out, /unsupported schema_version: cert.v3/); +}); +// ── dataset binding ────────────────────────────────────────────────────────── +test("--dataset mismatch is reported, not silently passed", async () => { + const res = await verifyCertificateV2(await loadEnvelope(), await prodKeys(), join(fixtures, "prod-keys.json")); + assert.equal(res.verdict, "DATASET_MISMATCH"); + assert.equal(res.checks.signature, "pass"); + assert.equal(res.checks.dataset_match, "fail"); +}); +//# sourceMappingURL=cert-v2.test.js.map \ No newline at end of file diff --git a/dist/cert-v2.test.js.map b/dist/cert-v2.test.js.map new file mode 100644 index 0000000..59c4c8e --- /dev/null +++ b/dist/cert-v2.test.js.map @@ -0,0 +1 @@ +{"version":3,"file":"cert-v2.test.js","sourceRoot":"","sources":["../src/cert-v2.test.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/cli.d.ts.map b/dist/cli.d.ts.map index 31b199e..781cf8f 100644 --- a/dist/cli.d.ts.map +++ b/dist/cli.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AA+DA,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwG1D"} \ No newline at end of file +{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAgEA,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA6G1D"} \ No newline at end of file diff --git a/dist/cli.js b/dist/cli.js index 859e91a..636397c 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2,6 +2,7 @@ import { fetchCert } from "./fetch-cert.js"; import { loadKeys } from "./keys.js"; import { verifyCertificate } from "./verify.js"; +import { isCertV2, verifyCertificateV2 } from "./cert-v2.js"; import { fetchReceipt, loadReceiptKey, verifyReceiptEnvelope } from "./receipt.js"; import { resolveArtifactKind } from "./resolve.js"; const HELP = `certifieddata-verify [options] @@ -126,12 +127,17 @@ export async function main(argv) { case "MALFORMED": return EXIT.MALFORMED; } } - // ── Certificate path (unchanged behavior) ────────────────────────────── + // ── Certificate path ─────────────────────────────────────────────────── + // cert.v1 and cert.v2 sign different bytes and name the signer in different + // places, so the schema decides which verifier runs. Both return the same + // VerifyResult, so output and exit codes are identical either way. let result; try { - const cert = await fetchCert(target, { offline: args.offline }); + const doc = await fetchCert(target, { offline: args.offline }); const keys = await loadKeys({ keysFile: args.keys, offline: args.offline, noCache: args.noCache }); - result = await verifyCertificate(cert, keys, args.dataset); + result = isCertV2(doc) + ? await verifyCertificateV2(doc, keys, args.dataset) + : await verifyCertificate(doc, keys, args.dataset); } catch (err) { const reason = err.message; @@ -220,9 +226,15 @@ function printHuman(r) { process.stdout.write(`${c.green("✓ VALID")} certification_id ${id}\n`); const label = r.key_label ? `${r.key_id} (${r.issuer}, ${r.key_label})` : `${r.key_id} (${r.issuer})`; process.stdout.write(` ${c.dim("signed by")} ${label}\n`); - const rows = (r.rows ?? 0).toLocaleString("en-US"); - const cols = (r.columns ?? 0).toLocaleString("en-US"); - process.stdout.write(` ${c.dim("algorithm")} ${r.algorithm} · ${rows} rows × ${cols} cols · signed ${r.signed_at}\n`); + // cert.v2 carries no column count, so only print a shape when we have + // one. Printing "0 rows × 0 cols" for a v2 certificate states something + // false about the artifact. + const shape = r.rows !== undefined && r.columns !== undefined + ? `${r.rows.toLocaleString("en-US")} rows × ${r.columns.toLocaleString("en-US")} cols · ` + : r.rows !== undefined + ? `${r.rows.toLocaleString("en-US")} records · ` + : ""; + process.stdout.write(` ${c.dim("algorithm")} ${r.algorithm} · ${shape}signed ${r.signed_at}\n`); if (r.checks.dataset_match === "pass") { process.stdout.write(` ${c.dim("dataset")} ${r.dataset_hash_actual} ${c.green("matches")}\n`); } diff --git a/dist/cli.js.map b/dist/cli.js.map index 877eda1..b632104 100644 --- a/dist/cli.js.map +++ b/dist/cli.js.map @@ -1 +1 @@ -{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/fetch-cert.d.ts b/dist/fetch-cert.d.ts index a658b62..fc9deb7 100644 --- a/dist/fetch-cert.d.ts +++ b/dist/fetch-cert.d.ts @@ -1,8 +1,29 @@ -import type { Certificate } from "./types.js"; -export declare const DEFAULT_CERT_API = "https://certifieddata.io/api/v1/certificates"; +/** + * Resolving a bare certificate id must land on bytes that are actually + * verifiable. + * + * `https://certifieddata.io/api/v1/certificates/:id` — the path this used to + * point at — returns 404; it was never deployed. + * + * `https://api.certifieddata.io/api/certificates/:id` does respond, but it + * serves a cert.v1-shaped *display projection* of a cert.v2 certificate. It + * carries the real signature bytes while the signature covers the v2 payload, + * not the projection, so verifying that document yields INVALID — which reads + * as tampering when nothing has been tampered with. + * + * `/signed-payload` is the envelope whose signature verifies over its own + * payload, so that is what a verifier has to ask for. + */ +export declare const DEFAULT_CERT_API = "https://api.certifieddata.io/api/certificates"; +export declare const CERT_ENVELOPE_SUFFIX = "/signed-payload"; export interface FetchCertOptions { apiBase?: string; offline?: boolean; } -export declare function fetchCert(idOrPathOrUrl: string, opts?: FetchCertOptions): Promise; +/** + * Returns the raw certificate document. Shape validation belongs to the + * verifier, not the fetcher: the document may be a cert.v1 certificate or a + * cert.v2 envelope, and deciding which is the caller's job. + */ +export declare function fetchCert(idOrPathOrUrl: string, opts?: FetchCertOptions): Promise>; //# sourceMappingURL=fetch-cert.d.ts.map \ No newline at end of file diff --git a/dist/fetch-cert.d.ts.map b/dist/fetch-cert.d.ts.map index fe49df0..e9e2682 100644 --- a/dist/fetch-cert.d.ts.map +++ b/dist/fetch-cert.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"fetch-cert.d.ts","sourceRoot":"","sources":["../src/fetch-cert.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C,eAAO,MAAM,gBAAgB,iDAAiD,CAAC;AAE/E,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,WAAW,CAAC,CAiBxG"} \ No newline at end of file +{"version":3,"file":"fetch-cert.d.ts","sourceRoot":"","sources":["../src/fetch-cert.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB,kDAAkD,CAAC;AAChF,eAAO,MAAM,oBAAoB,oBAAoB,CAAC;AAEtD,MAAM,WAAW,gBAAgB;IAC/B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,aAAa,EAAE,MAAM,EACrB,IAAI,GAAE,gBAAqB,GAC1B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAqBlC"} \ No newline at end of file diff --git a/dist/fetch-cert.js b/dist/fetch-cert.js index beeeb4c..20565b3 100644 --- a/dist/fetch-cert.js +++ b/dist/fetch-cert.js @@ -1,10 +1,34 @@ import { readFile } from "node:fs/promises"; -export const DEFAULT_CERT_API = "https://certifieddata.io/api/v1/certificates"; +/** + * Resolving a bare certificate id must land on bytes that are actually + * verifiable. + * + * `https://certifieddata.io/api/v1/certificates/:id` — the path this used to + * point at — returns 404; it was never deployed. + * + * `https://api.certifieddata.io/api/certificates/:id` does respond, but it + * serves a cert.v1-shaped *display projection* of a cert.v2 certificate. It + * carries the real signature bytes while the signature covers the v2 payload, + * not the projection, so verifying that document yields INVALID — which reads + * as tampering when nothing has been tampered with. + * + * `/signed-payload` is the envelope whose signature verifies over its own + * payload, so that is what a verifier has to ask for. + */ +export const DEFAULT_CERT_API = "https://api.certifieddata.io/api/certificates"; +export const CERT_ENVELOPE_SUFFIX = "/signed-payload"; +/** + * Returns the raw certificate document. Shape validation belongs to the + * verifier, not the fetcher: the document may be a cert.v1 certificate or a + * cert.v2 envelope, and deciding which is the caller's job. + */ export async function fetchCert(idOrPathOrUrl, opts = {}) { if (idOrPathOrUrl === "-") { return parseCertJson(await readStdin()); } - if (idOrPathOrUrl.endsWith(".json") || idOrPathOrUrl.startsWith("./") || idOrPathOrUrl.startsWith("/")) { + if (idOrPathOrUrl.endsWith(".json") || + idOrPathOrUrl.startsWith("./") || + idOrPathOrUrl.startsWith("/")) { return parseCertJson(await readFile(idOrPathOrUrl, "utf8")); } if (/^https?:\/\//.test(idOrPathOrUrl)) { @@ -16,7 +40,7 @@ export async function fetchCert(idOrPathOrUrl, opts = {}) { throw new Error("cannot resolve certification id in --offline mode (pass a local file)"); } const base = opts.apiBase ?? DEFAULT_CERT_API; - const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}`; + const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}${CERT_ENVELOPE_SUFFIX}`; return parseCertJson(await fetchText(url)); } async function fetchText(url) { diff --git a/dist/fetch-cert.js.map b/dist/fetch-cert.js.map index 519366c..9dd0a78 100644 --- a/dist/fetch-cert.js.map +++ b/dist/fetch-cert.js.map @@ -1 +1 @@ -{"version":3,"file":"fetch-cert.js","sourceRoot":"","sources":["../src/fetch-cert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAG5C,MAAM,CAAC,MAAM,gBAAgB,GAAG,8CAA8C,CAAC;AAO/E,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,aAAqB,EAAE,OAAyB,EAAE;IAChF,IAAI,aAAa,KAAK,GAAG,EAAE,CAAC;QAC1B,OAAO,aAAa,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;IAC1C,CAAC;IACD,IAAI,aAAa,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,aAAa,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACvG,OAAO,aAAa,CAAC,MAAM,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9D,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC;QACvC,IAAI,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxE,OAAO,aAAa,CAAC,MAAM,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC;IACvD,CAAC;IACD,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;IAC3F,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,IAAI,gBAAgB,CAAC;IAC9C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,kBAAkB,CAAC,aAAa,CAAC,EAAE,CAAC;IAC9E,OAAO,aAAa,CAAC,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,GAAW;IAClC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,aAAa,GAAG,EAAE,CAAC,CAAC;IACnE,OAAO,GAAG,CAAC,IAAI,EAAE,CAAC;AACpB,CAAC;AAED,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAgB,CAAC;AACzC,CAAC;AAED,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAe,CAAC,CAAC;IAChF,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC"} \ No newline at end of file +{"version":3,"file":"fetch-cert.js","sourceRoot":"","sources":["../src/fetch-cert.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,+CAA+C,CAAC;AAChF,MAAM,CAAC,MAAM,oBAAoB,GAAG,iBAAiB,CAAC;AAOtD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,aAAqB,EACrB,OAAyB,EAAE;IAE3B,IAAI,aAAa,KAAK,GAAG,EAAE,CAAC;QAC1B,OAAO,aAAa,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;IAC1C,CAAC;IACD,IACE,aAAa,CAAC,QAAQ,CAAC,OAAO,CAAC;QAC/B,aAAa,CAAC,UAAU,CAAC,IAAI,CAAC;QAC9B,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC,EAC7B,CAAC;QACD,OAAO,aAAa,CAAC,MAAM,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9D,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,CAAC,aAAa,CAAC,EAAE,CAAC;QACvC,IAAI,IAAI,CAAC,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,oCAAoC,CAAC,CAAC;QACxE,OAAO,aAAa,CAAC,MAAM,SAAS,CAAC,aAAa,CAAC,CAAC,CAAC;IACvD,CAAC;IACD,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;IAC3F,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,IAAI,gBAAgB,CAAC;IAC9C,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,kBAAkB,CAAC,aAAa,CAAC,GAAG,oBAAoB,EAAE,CAAC;IACrG,OAAO,aAAa,CAAC,MAAM,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC;AAC7C,CAAC;AAED,KAAK,UAAU,SAAS,CAAC,GAAW;IAClC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;IAC7B,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,aAAa,GAAG,EAAE,CAAC,CAAC;IACnE,OAAO,GAAG,CAAC,IAAI,EAAE,CAAC;AACpB,CAAC;AAED,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAA4B,CAAC;AACrD,CAAC;AAED,KAAK,UAAU,SAAS;IACtB,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,KAAK,EAAE,MAAM,KAAK,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACxC,MAAM,CAAC,IAAI,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAE,KAAgB,CAAC,CAAC;IAClF,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 9630093..5b9fe7c 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,7 +1,9 @@ export { verifyCertificate } from "./verify.js"; +export { verifyCertificateV2, isCertV2, toEnvelope } from "./cert-v2.js"; +export type { CertV2Payload, CertV2Envelope, CertV2Signature } from "./cert-v2.js"; export { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; export { sha256Hex, sha256File, formatDigest, parseDigest } from "./hash.js"; export { loadKeys, findKey, DEFAULT_KEYS_URL } from "./keys.js"; -export { fetchCert, DEFAULT_CERT_API } from "./fetch-cert.js"; +export { fetchCert, DEFAULT_CERT_API, CERT_ENVELOPE_SUFFIX } from "./fetch-cert.js"; export type { Certificate, KeyDoc, KeyEntry, VerifyResult, Verdict, CheckResult, } from "./types.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 77a1e8a..d576e14 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC9D,YAAY,EACV,WAAW,EACX,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,OAAO,EACP,WAAW,GACZ,MAAM,YAAY,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,mBAAmB,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACzE,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACpF,YAAY,EACV,WAAW,EACX,MAAM,EACN,QAAQ,EACR,YAAY,EACZ,OAAO,EACP,WAAW,GACZ,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 21c58f6..1e3ddf9 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,6 +1,7 @@ export { verifyCertificate } from "./verify.js"; +export { verifyCertificateV2, isCertV2, toEnvelope } from "./cert-v2.js"; export { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; export { sha256Hex, sha256File, formatDigest, parseDigest } from "./hash.js"; export { loadKeys, findKey, DEFAULT_KEYS_URL } from "./keys.js"; -export { fetchCert, DEFAULT_CERT_API } from "./fetch-cert.js"; +export { fetchCert, DEFAULT_CERT_API, CERT_ENVELOPE_SUFFIX } from "./fetch-cert.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map index 2b665bc..40cb0f4 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,mBAAmB,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAEzE,OAAO,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAChE,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC"} \ No newline at end of file diff --git a/dist/keys.d.ts b/dist/keys.d.ts index 2d97bf2..1c8104f 100644 --- a/dist/keys.d.ts +++ b/dist/keys.d.ts @@ -1,5 +1,17 @@ import type { KeyDoc, KeyEntry } from "./types.js"; -export declare const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/certifieddata-keys.json"; +/** + * The pinned trust root. + * + * This is the document the issuer actually publishes, and the one every + * certificate's own `public_key_url` points at. It is pinned here rather than + * read out of the certificate: a URL taken from an unverified document would + * let whoever supplied the document choose the keys it is checked against. + * + * `certifieddata-keys.json` — the path this used to point at — returns 404 and + * was never deployed. Both dialects are accepted by `parseKeyDoc`, so if that + * document is published later it will work without a code change. + */ +export declare const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/signing-keys.json"; export declare const CACHE_PATH: string; export declare const CACHE_TTL_MS: number; export interface LoadKeysOptions { diff --git a/dist/keys.d.ts.map b/dist/keys.d.ts.map index 6fc6d06..b08b3dd 100644 --- a/dist/keys.d.ts.map +++ b/dist/keys.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEnD,eAAO,MAAM,gBAAgB,iEAAiE,CAAC;AAC/F,eAAO,MAAM,UAAU,QAAiD,CAAC;AACzE,eAAO,MAAM,YAAY,QAAsB,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoC1E;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAExE"} \ No newline at end of file +{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,gBAAgB,2DAA2D,CAAC;AACzF,eAAO,MAAM,UAAU,QAAiD,CAAC;AACzE,eAAO,MAAM,YAAY,QAAsB,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,QAAQ,CAAC,IAAI,GAAE,eAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,CAoC1E;AAED,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAExE"} \ No newline at end of file diff --git a/dist/keys.js b/dist/keys.js index 7be5938..948a2bd 100644 --- a/dist/keys.js +++ b/dist/keys.js @@ -1,7 +1,19 @@ import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -export const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/certifieddata-keys.json"; +/** + * The pinned trust root. + * + * This is the document the issuer actually publishes, and the one every + * certificate's own `public_key_url` points at. It is pinned here rather than + * read out of the certificate: a URL taken from an unverified document would + * let whoever supplied the document choose the keys it is checked against. + * + * `certifieddata-keys.json` — the path this used to point at — returns 404 and + * was never deployed. Both dialects are accepted by `parseKeyDoc`, so if that + * document is published later it will work without a code change. + */ +export const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/signing-keys.json"; export const CACHE_PATH = join(homedir(), ".certifieddata", "keys.json"); export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; export async function loadKeys(opts = {}) { @@ -44,12 +56,76 @@ export async function loadKeys(opts = {}) { export function findKey(doc, keyId) { return doc.keys.find((k) => k.key_id === keyId); } +/** + * Two dialects are in circulation and both have to work: + * + * this verifier's own shape keys[].public_key, algorithm "ed25519", + * per-key revoked_at + * signing-keys.v1 (published) keys[].public_key_pem, algorithm "Ed25519", + * revocation in top-level revoked[]/retired[] + * + * Normalizing here rather than at each call site means the difference cannot + * turn into a wrong verdict. Two of these differences are security-relevant: + * a mis-read `algorithm` yields UNKNOWN_KEY on a good key, and an unmapped + * `revoked[]` would let a revoked key keep verifying. + */ function parseKeyDoc(body) { const parsed = JSON.parse(body); if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.keys)) { throw new Error("invalid key document: missing keys[]"); } - return parsed; + const revokedAtFor = buildRevocationIndex(parsed); + const keys = parsed.keys.map((raw) => { + const keyId = String(raw.key_id ?? ""); + const material = (raw.public_key ?? raw.public_key_pem ?? raw.public_key_raw_b64url); + if (!keyId || typeof material !== "string" || material.length === 0) { + throw new Error(`invalid key document: entry ${keyId || "(no key_id)"} has no public key`); + } + return { + ...raw, + key_id: keyId, + // CRLF appears in the published PEM; node's createPublicKey is fussier + // about that than it needs to be. + public_key: material.replace(/\r\n/g, "\n"), + algorithm: String(raw.algorithm ?? "").toLowerCase(), + revoked_at: raw.revoked_at ?? revokedAtFor.get(keyId) ?? null, + }; + }); + return { ...parsed, issuer: String(parsed.issuer ?? ""), keys }; +} +/** + * signing-keys.v1 lists revocations separately from the key entries. Entries + * may be bare key_id strings or objects; anything else is refused rather than + * ignored, because silently skipping a revocation record we cannot read would + * mean treating a possibly-revoked key as good. + */ +function buildRevocationIndex(doc) { + const out = new Map(); + for (const field of ["revoked", "retired"]) { + const list = doc[field]; + if (list === undefined || list === null) + continue; + if (!Array.isArray(list)) { + throw new Error(`invalid key document: ${field} must be an array`); + } + for (const entry of list) { + if (typeof entry === "string") { + out.set(entry, `listed in ${field}[]`); + continue; + } + if (entry && typeof entry === "object") { + const e = entry; + const id = e.key_id ?? e.id; + if (typeof id === "string" && id.length > 0) { + const when = e.revoked_at ?? e.retired_at ?? e.at; + out.set(id, typeof when === "string" ? when : `listed in ${field}[]`); + continue; + } + } + throw new Error(`invalid key document: unreadable entry in ${field}[] — refusing to ignore a revocation record`); + } + } + return out; } async function readCacheIfFresh(path) { try { diff --git a/dist/keys.js.map b/dist/keys.js.map index 621ac04..f3ed1ce 100644 --- a/dist/keys.js.map +++ b/dist/keys.js.map @@ -1 +1 @@ -{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACpE,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAG1C;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,wDAAwD,CAAC;AACzF,MAAM,CAAC,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,gBAAgB,EAAE,WAAW,CAAC,CAAC;AACzE,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAUhD,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,OAAwB,EAAE;IACvD,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QAClB,OAAO,WAAW,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IAC5D,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,IAAI,UAAU,CAAC;IAE/C,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QACjB,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;YACjB,MAAM,IAAI,KAAK,CAAC,4DAA4D,CAAC,CAAC;QAChF,CAAC;QACD,OAAO,WAAW,CAAC,MAAM,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,CAAC;IACxD,CAAC;IAED,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;QAClB,MAAM,KAAK,GAAG,MAAM,gBAAgB,CAAC,SAAS,CAAC,CAAC;QAChD,IAAI,KAAK;YAAE,OAAO,KAAK,CAAC;IAC1B,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,gBAAgB,CAAC;IACzC,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,CAAC,CAAC;QAC7B,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACnD,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC1B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC;YAClB,MAAM,KAAK,GAAG,MAAM,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;YAClE,IAAI,KAAK;gBAAE,OAAO,WAAW,CAAC,KAAK,CAAC,CAAC;QACvC,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,6BAA6B,GAAG,KAAM,GAAa,CAAC,OAAO,EAAE,CAAC,CAAC;IACjF,CAAC;IAED,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,CAAC,IAAI,CAAC,OAAO;QAAE,MAAM,UAAU,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IACrD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,GAAW,EAAE,KAAa;IAChD,OAAO,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,KAAK,CAAC,CAAC;AAClD,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,SAAS,WAAW,CAAC,IAAY;IAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAA4B,CAAC;IAC3D,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC;QACzE,MAAM,IAAI,KAAK,CAAC,sCAAsC,CAAC,CAAC;IAC1D,CAAC;IAED,MAAM,YAAY,GAAG,oBAAoB,CAAC,MAAM,CAAC,CAAC;IAElD,MAAM,IAAI,GAAgB,MAAM,CAAC,IAAkC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE;QAC9E,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;QACvC,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC,cAAc,IAAI,GAAG,CAAC,qBAAqB,CAEtE,CAAC;QACd,IAAI,CAAC,KAAK,IAAI,OAAO,QAAQ,KAAK,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACpE,MAAM,IAAI,KAAK,CAAC,+BAA+B,KAAK,IAAI,aAAa,oBAAoB,CAAC,CAAC;QAC7F,CAAC;QACD,OAAO;YACL,GAAI,GAA2B;YAC/B,MAAM,EAAE,KAAK;YACb,uEAAuE;YACvE,kCAAkC;YAClC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC;YAC3C,SAAS,EAAE,MAAM,CAAC,GAAG,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,WAAW,EAAe;YACjE,UAAU,EAAG,GAAG,CAAC,UAAwC,IAAI,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,IAAI;SAC7F,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,OAAO,EAAE,GAAI,MAA4B,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC;AACzF,CAAC;AAED;;;;;GAKG;AACH,SAAS,oBAAoB,CAAC,GAA4B;IACxD,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,KAAK,MAAM,KAAK,IAAI,CAAC,SAAS,EAAE,SAAS,CAAU,EAAE,CAAC;QACpD,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC;QACxB,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,IAAI;YAAE,SAAS;QAClD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;YACzB,MAAM,IAAI,KAAK,CAAC,yBAAyB,KAAK,mBAAmB,CAAC,CAAC;QACrE,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;YACzB,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;gBAC9B,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC,CAAC;gBACvC,SAAS;YACX,CAAC;YACD,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;gBACvC,MAAM,CAAC,GAAG,KAAgC,CAAC;gBAC3C,MAAM,EAAE,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,EAAE,CAAC;gBAC5B,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC5C,MAAM,IAAI,GAAG,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,EAAE,CAAC;oBAClD,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,aAAa,KAAK,IAAI,CAAC,CAAC;oBACtE,SAAS;gBACX,CAAC;YACH,CAAC;YACD,MAAM,IAAI,KAAK,CACb,6CAA6C,KAAK,6CAA6C,CAChG,CAAC;QACJ,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,KAAK,UAAU,gBAAgB,CAAC,IAAY;IAC1C,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3B,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,OAAO,GAAG,YAAY;YAAE,OAAO,IAAI,CAAC;QACvD,OAAO,WAAW,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,KAAK,UAAU,UAAU,CAAC,IAAY,EAAE,IAAY;IAClD,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAChD,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;AACtC,CAAC"} \ No newline at end of file diff --git a/dist/resolve.d.ts.map b/dist/resolve.d.ts.map index 1ecc699..d2ab9cd 100644 --- a/dist/resolve.d.ts.map +++ b/dist/resolve.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,YAAY,GAAG,aAAa,GAAG,SAAS,CAAC;AAErD,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7C,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9E,OAAO,CAAC,UAAU,CAAC,CAgErB"} \ No newline at end of file +{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAgBA,MAAM,MAAM,YAAY,GAAG,aAAa,GAAG,SAAS,CAAC;AAErD,MAAM,MAAM,UAAU,GAClB;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7C,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,MAAM,EACd,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,cAAc,CAAC,EAAE,MAAM,CAAA;CAAO,GAC9E,OAAO,CAAC,UAAU,CAAC,CA+ErB"} \ No newline at end of file diff --git a/dist/resolve.js b/dist/resolve.js index 86b9575..05caf11 100644 --- a/dist/resolve.js +++ b/dist/resolve.js @@ -19,7 +19,7 @@ export async function resolveArtifactKind(target, opts = {}) { // Endpoint-URL hints. if (/\/api\/payments\/verify\//.test(target)) return { kind: "receipt", via: "url-path" }; - if (/\/api\/v1\/certificates\//.test(target)) + if (/\/api\/(v1\/)?certificates?\//.test(target)) return { kind: "certificate", via: "url-path" }; // Local file / stdin: sniff the JSON shape. const looksLocal = target === "-" || @@ -30,11 +30,20 @@ export async function resolveArtifactKind(target, opts = {}) { if (looksLocal && target !== "-") { try { const parsed = JSON.parse(await readFile(target, "utf8")); + // A cert.v2 envelope names its schema on the payload, not at the top + // level — production serves the outer document as + // "certifieddata.manifest.v1" — so sniffing only the top level would + // miss every real certificate. const schema = parsed.schema_version ?? + parsed.payload?.schema_version ?? parsed.receipt?.schema_version; + const payloadSchema = parsed.payload + ?.schema_version; if (schema === "payment_receipt.v1") return { kind: "receipt", via: "local-schema" }; - if (typeof schema === "string" && schema.startsWith("cert.")) { + if (isCertificateSchema(schema) || + isCertificateSchema(payloadSchema) || + schema === "certifieddata.manifest.v1") { return { kind: "certificate", via: "local-schema" }; } // Envelope shape without schema — a verify-endpoint dump. @@ -84,4 +93,17 @@ export async function resolveArtifactKind(target, opts = {}) { return { kind: "receipt", via: "probe" }; return { kind: "not_found", via: "probe" }; } +/** + * Certificate schema names seen in the wild: + * cert.v1, cert.v2 — the payload's own schema_version + * certifieddata.cert.v1 — the public display projection + * certifieddata.manifest.v1 — the signed-payload envelope + */ +function isCertificateSchema(schema) { + if (typeof schema !== "string") + return false; + return (schema.startsWith("cert.") || + schema.startsWith("certifieddata.cert.") || + schema.startsWith("certifieddata.manifest.")); +} //# sourceMappingURL=resolve.js.map \ No newline at end of file diff --git a/dist/resolve.js.map b/dist/resolve.js.map index 5dee692..813a628 100644 --- a/dist/resolve.js.map +++ b/dist/resolve.js.map @@ -1 +1 @@ -{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/fixtures/prod-keys.json b/fixtures/prod-keys.json new file mode 100644 index 0000000..e79b6fe --- /dev/null +++ b/fixtures/prod-keys.json @@ -0,0 +1,13 @@ +{ + "issuer": "CertifiedData.io", + "keys": [ + { + "key_id": "ed25519-prod-2025-02", + "public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAGvAJNlssfFuZp4TNNr0eZhYNHoQSgng92z8lLjKcwLM=\n-----END PUBLIC KEY-----\n", + "algorithm": "ed25519", + "created_at": "2026-02-22T18:26:46.293Z", + "revoked_at": null, + "label": "production signing key" + } + ] +} diff --git a/fixtures/valid-cert-v2.json b/fixtures/valid-cert-v2.json new file mode 100644 index 0000000..8dd6b3e --- /dev/null +++ b/fixtures/valid-cert-v2.json @@ -0,0 +1,60 @@ +{ + "schema_version": "certifieddata.manifest.v1", + "payload": { + "hashes": { + "datasets": { + "2026-09-07.56f57741adc7.public_audit_export.v1.json": { + "sha256": "cc437f2cc9e2e58fc22a9c88bd33c001305006aadcebf9af2eb7d9bfb0b34e0e", + "size_bytes": 780 + } + }, + "certificate_payload_sha256": "d611a0115feed01a3bf866258cdac97aaf5c6c310cfea2525e3aa39d5216f08e" + }, + "issuer": { + "name": "Certified Data LLC", + "environment": "production", + "signature_alg": "Ed25519", + "signing_key_id": "ed25519-prod-2025-02" + }, + "subject": { + "user_id": "536f5e7e-7c9d-4f25-ba3a-319230b0c6cf", + "dataset_name": "2026-09-07.56f57741adc7.public_audit_export.v1.json" + }, + "manifest": { + "engine": "notary-s2s", + "outputs": { + "dataset_ephemeral": true + }, + "manifest_id": "55c1a91a-25b7-415b-9582-cad5e25a6f68", + "record_count": 1, + "manifest_mode": "notary" + }, + "artifacts": { + "pdf": { + "path": "certs/d6da041f-a70c-4945-93b7-dff1e42a00d0.pdf", + "bucket": "certificates" + }, + "verification_url": "https://certifieddata.io/verify/d6da041f-a70c-4945-93b7-dff1e42a00d0" + }, + "issued_at": "2026-09-07T00:31:13.652Z", + "provenance": { + "audit_vault_entry_ids": [], + "audit_vault_retention_years": 7 + }, + "hash_method": "SHA-256", + "artifact_hash": "cc437f2cc9e2e58fc22a9c88bd33c001305006aadcebf9af2eb7d9bfb0b34e0e", + "certificate_id": "d6da041f-a70c-4945-93b7-dff1e42a00d0", + "schema_version": "cert.v2", + "inner_artifacts": {}, + "certificate_type": "MANIFEST", + "artifact_filename": "2026-09-07.56f57741adc7.public_audit_export.v1.json", + "artifact_mime_type": "application/json", + "certification_scope": "This certificate covers the delivered ZIP artifact and the listed inner files.", + "hashing_methodology_version": "artifact-hash.v1" + }, + "signature": { + "alg": "Ed25519", + "key_id": "ed25519-prod-2025-02", + "value": "z6wuaC86LVkXnYLXtCpS6Xui3I1/piGyFyRdt66iLfuuIlip/2atKf0M7qQbJIfEAjZpTcQW6bdPhbL1g1/VBA==" + } +} diff --git a/package.json b/package.json index 7db81cc..be1d5a3 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "prepare": "npm run build", "typecheck": "tsc --noEmit", "lint": "eslint src/", - "test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cli.test.js dist/receipt.test.js dist/receipt-vectors.test.js dist/exit-codes.test.js dist/bin-invocation.test.js", + "test": "tsc -p tsconfig.json && node --test dist/canonicalize.test.js dist/verify.test.js dist/cert-v2.test.js dist/cli.test.js dist/receipt.test.js dist/receipt-vectors.test.js dist/exit-codes.test.js dist/bin-invocation.test.js", "fixtures": "node fixtures/generate.mjs", "prepublishOnly": "npm run build && npm test" }, diff --git a/src/cert-v2.test.ts b/src/cert-v2.test.ts new file mode 100644 index 0000000..7f020ab --- /dev/null +++ b/src/cert-v2.test.ts @@ -0,0 +1,223 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { isCertV2, toEnvelope, verifyCertificateV2 } from "./cert-v2.js"; +import type { KeyDoc } from "./types.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const fixtures = join(here, "..", "fixtures"); + +/** Fresh copy each time — several tests mutate the document. */ +async function loadEnvelope(): Promise> { + return JSON.parse(await readFile(join(fixtures, "valid-cert-v2.json"), "utf8")) as Record< + string, + unknown + >; +} + +async function prodKeys(): Promise { + return JSON.parse(await readFile(join(fixtures, "prod-keys.json"), "utf8")) as KeyDoc; +} + +// ── detection ──────────────────────────────────────────────────────────────── + +test("isCertV2 detects the production envelope", async () => { + const env = await loadEnvelope(); + // Production's OUTER schema_version is "certifieddata.manifest.v1"; only the + // payload says cert.v2. Detection that looked only at the top level would + // miss every real certificate. + assert.equal(env.schema_version, "certifieddata.manifest.v1"); + assert.equal(isCertV2(env), true); +}); + +test("isCertV2 rejects v1 and non-objects", () => { + assert.equal(isCertV2({ schema_version: "cert.v1" }), false); + assert.equal(isCertV2(null), false); + assert.equal(isCertV2("cert.v2"), false); +}); + +// ── the real certificate ───────────────────────────────────────────────────── + +test("verifies a real production cert.v2 certificate", async () => { + const res = await verifyCertificateV2(await loadEnvelope(), await prodKeys()); + assert.equal(res.verdict, "VALID"); + assert.equal(res.checks.signature, "pass"); + assert.equal(res.checks.key_trust, "pass"); + assert.equal(res.key_id, "ed25519-prod-2025-02"); + assert.equal(res.certification_id, "d6da041f-a70c-4945-93b7-dff1e42a00d0"); +}); + +// ── signature spellings ────────────────────────────────────────────────────── +// +// These are the regression tests for the bug that made the first cut of this +// feature useless: production serves `signature` as an OBJECT, and an +// implementation that required a base64 string reported MALFORMED on all 577 +// issued certificates. + +test('accepts the {alg, key_id, value} signature object that /signed-payload serves', async () => { + const env = await loadEnvelope(); + assert.equal(typeof env.signature, "object", "fixture should carry the production object form"); + assert.ok((env.signature as Record).value); + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); + +test('accepts the {alg, key_id, sig} signature object that /api/certificates/:id serves', async () => { + const env = await loadEnvelope(); + const sig = env.signature as Record; + env.signature = { alg: sig.alg, key_id: sig.key_id, sig: sig.value }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); + +test("accepts a bare base64 signature string", async () => { + const env = await loadEnvelope(); + env.signature = (env.signature as Record).value; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); + +test("refuses a v2 payload with no detached signature", async () => { + const env = await loadEnvelope(); + delete env.signature; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /detached signature/); +}); + +test("refuses a signature object carrying no usable value", async () => { + const env = await loadEnvelope(); + env.signature = { alg: "Ed25519", key_id: "ed25519-prod-2025-02" }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /detached signature/); +}); + +test("refuses a signature that is not 64 bytes", async () => { + const env = await loadEnvelope(); + env.signature = Buffer.from("too short").toString("base64"); + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /64 bytes/); +}); + +// ── key selection is a security boundary ───────────────────────────────────── + +test("selects the key named in the SIGNED payload, not the envelope", async () => { + const env = await loadEnvelope(); + const sig = env.signature as Record; + // The envelope is not covered by the signature. If key selection trusted it, + // an attacker could point verification at a key of their choosing. + env.signature = { ...sig, key_id: "attacker-supplied-key" }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /envelope names key_id attacker-supplied-key/); + assert.equal(res.checks.signature, "skipped"); +}); + +test("tolerates the envelope omitting key_id entirely", async () => { + const env = await loadEnvelope(); + const sig = env.signature as Record; + env.signature = { alg: sig.alg, value: sig.value }; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "VALID"); +}); + +// ── trust and revocation ───────────────────────────────────────────────────── + +test("rejects an unknown signing key", async () => { + const keys: KeyDoc = { issuer: "CertifiedData.io", keys: [] }; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "UNKNOWN_KEY"); + assert.equal(res.checks.signature, "skipped"); +}); + +test("rejects a revoked signing key", async () => { + const keys = await prodKeys(); + keys.keys[0].revoked_at = "2026-09-01T00:00:00Z"; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "UNKNOWN_KEY"); + assert.match(res.reason, /revoked/); +}); + +test('accepts algorithm "Ed25519" as well as "ed25519"', async () => { + const keys = await prodKeys(); + // This is how the issuer's own published keys document spells it. Returning + // UNKNOWN_KEY on the capital E would be a security verdict for a cosmetic + // difference. + keys.keys[0].algorithm = "Ed25519" as "ed25519"; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "VALID"); +}); + +test("rejects a key that is not ed25519 at all", async () => { + const keys = await prodKeys(); + keys.keys[0].algorithm = "rsa" as "ed25519"; + const res = await verifyCertificateV2(await loadEnvelope(), keys); + assert.equal(res.verdict, "UNKNOWN_KEY"); + assert.match(res.reason, /not ed25519/); +}); + +// ── tamper detection ───────────────────────────────────────────────────────── + +test("rejects a tampered v2 payload", async () => { + const env = await loadEnvelope(); + (env.payload as Record).artifact_hash = "0".repeat(64); + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "INVALID"); + assert.equal(res.checks.signature, "fail"); +}); + +test("rejects a payload with a field appended", async () => { + const env = await loadEnvelope(); + (env.payload as Record).injected = "not in the signed bytes"; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "INVALID"); + assert.equal(res.checks.signature, "fail"); +}); + +test("rejects a payload with a field removed", async () => { + const env = await loadEnvelope(); + delete (env.payload as Record).certification_scope; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "INVALID"); +}); + +// ── shape validation ───────────────────────────────────────────────────────── + +test("reports a missing required field rather than failing the signature", async () => { + const env = await loadEnvelope(); + delete (env.payload as Record).certificate_id; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /certificate_id/); +}); + +test("rejects a non-hex artifact_hash", async () => { + const env = await loadEnvelope(); + (env.payload as Record).artifact_hash = "sha256:not-hex"; + const res = await verifyCertificateV2(env, await prodKeys()); + assert.equal(res.verdict, "MALFORMED"); + assert.match(res.reason, /64 hex/); +}); + +test("toEnvelope explains an unsupported schema rather than throwing", () => { + const out = toEnvelope({ payload: { schema_version: "cert.v3" }, signature: "x".repeat(88) }); + assert.equal(typeof out, "string"); + assert.match(out as string, /unsupported schema_version: cert.v3/); +}); + +// ── dataset binding ────────────────────────────────────────────────────────── + +test("--dataset mismatch is reported, not silently passed", async () => { + const res = await verifyCertificateV2( + await loadEnvelope(), + await prodKeys(), + join(fixtures, "prod-keys.json"), + ); + assert.equal(res.verdict, "DATASET_MISMATCH"); + assert.equal(res.checks.signature, "pass"); + assert.equal(res.checks.dataset_match, "fail"); +}); diff --git a/src/cert-v2.ts b/src/cert-v2.ts new file mode 100644 index 0000000..ede7238 --- /dev/null +++ b/src/cert-v2.ts @@ -0,0 +1,280 @@ +// cert.v2 verification. +// +// Differences from cert.v1 that matter for verification: +// +// 1. The signature is NOT a field inside the signed document. In v1 the +// signature lives on the certificate and is stripped before +// canonicalization; in v2 the payload is signed as-is and the signature +// travels beside it in an envelope. So v2 canonicalizes the WHOLE payload +// — nothing is removed. +// 2. The signer is named at payload.issuer.signing_key_id, not cert.key_id. +// 3. The artifact digest is bare lowercase hex at payload.artifact_hash, +// with no "sha256:" prefix. +// 4. There is no rows/columns/algorithm triple. manifest.record_count and +// manifest.engine are the nearest equivalents and are display-only. +// +// Signed bytes are Ed25519 over RFC 8785 (JCS) of the payload. Confirmed +// empirically against the live production certificate in +// fixtures/valid-cert-v2.json: of JCS(payload), JSON.stringify(payload), +// JCS(envelope minus signature) and JCS(payload minus the self-hash), only +// JCS(payload) verifies. +// +// On the envelope's signature field: production serves it as an OBJECT, not a +// bare base64 string — +// +// /api/certificates/:id/signed-payload -> {alg, key_id, value} +// /api/certificates/:id -> {alg, key_id, sig} +// +// so both spellings are accepted, as is a plain string. A verifier that only +// accepted the string form would report MALFORMED on every certificate the +// company has issued. + +import { createPublicKey, verify as cryptoVerify } from "node:crypto"; +import { canonicalizeToBytes } from "./canonicalize.js"; +import { sha256File, formatDigest } from "./hash.js"; +import { findKey } from "./keys.js"; +import type { KeyDoc, VerifyResult } from "./types.js"; + +export interface CertV2Issuer { + name?: string; + signing_key_id: string; + signature_alg?: string; + environment?: string; +} + +export interface CertV2Payload { + schema_version: "cert.v2"; + certificate_id: string; + certificate_type?: string; + issued_at: string; + artifact_hash: string; + hash_method?: string; + issuer: CertV2Issuer; + subject?: Record; + manifest?: { engine?: string; record_count?: number; [k: string]: unknown }; + [k: string]: unknown; +} + +/** The signature as production actually serves it, or as a bare base64 string. */ +export type CertV2Signature = + | string + | { value?: string; sig?: string; signature?: string; alg?: string; key_id?: string }; + +export interface CertV2Envelope { + payload: CertV2Payload; + /** base64 Ed25519, already unwrapped from whichever spelling arrived. */ + signature: string; + signature_alg?: string; + /** key_id as claimed by the UNSIGNED envelope. Never used to select a key. */ + envelope_key_id?: string; +} + +/** True when doc is a v2 envelope or a bare v2 payload carrying a sibling signature. */ +export function isCertV2(doc: unknown): boolean { + if (!doc || typeof doc !== "object") return false; + const d = doc as Record; + if (d.schema_version === "cert.v2") return true; + const p = d.payload as Record | undefined; + return !!p && typeof p === "object" && p.schema_version === "cert.v2"; +} + +/** + * Pull the base64 signature out of whichever shape arrived. + * Returns null when there is nothing usable, so the caller can say so plainly. + */ +function unwrapSignature(raw: unknown): { b64: string; alg?: string; keyId?: string } | null { + if (typeof raw === "string") { + return raw.length > 0 ? { b64: raw } : null; + } + if (raw && typeof raw === "object") { + const o = raw as Record; + // `value` is what /signed-payload emits; `sig` is what the base route emits. + const b64 = [o.value, o.sig, o.signature].find((v) => typeof v === "string" && v.length > 0); + if (typeof b64 !== "string") return null; + return { + b64, + alg: typeof o.alg === "string" ? o.alg : undefined, + keyId: typeof o.key_id === "string" ? o.key_id : undefined, + }; + } + return null; +} + +/** + * Normalize either shape into an envelope. + * Accepts {payload, signature} or a bare payload with a sibling signature. + */ +export function toEnvelope(doc: Record): CertV2Envelope | string { + const hasNested = !!doc.payload && typeof doc.payload === "object"; + const payload = (hasNested ? doc.payload : doc) as CertV2Payload; + + const unwrapped = unwrapSignature(doc.signature); + if (!unwrapped) { + return "cert.v2 requires a detached signature alongside the payload; none was present"; + } + if (payload.schema_version !== "cert.v2") { + return `unsupported schema_version: ${String(payload.schema_version)}`; + } + return { + payload, + signature: unwrapped.b64, + signature_alg: + unwrapped.alg ?? (typeof doc.signature_alg === "string" ? doc.signature_alg : undefined), + envelope_key_id: + unwrapped.keyId ?? (typeof doc.signing_key_id === "string" ? doc.signing_key_id : undefined), + }; +} + +export async function verifyCertificateV2( + doc: Record, + trustedKeys: KeyDoc, + datasetPath?: string, +): Promise { + const result = blankV2Result(); + + const env = toEnvelope(doc); + if (typeof env === "string") return finish(result, "MALFORMED", env); + + const { payload, signature } = env; + const shapeError = validateV2Shape(payload); + if (shapeError) return finish(result, "MALFORMED", shapeError); + + result.certification_id = payload.certificate_id; + result.issuer = payload.issuer?.name ?? null; + result.signed_at = payload.issued_at; + result.algorithm = payload.manifest?.engine ?? payload.certificate_type ?? null; + result.dataset_hash_expected = formatDigest(payload.artifact_hash.toLowerCase()); + if (typeof payload.manifest?.record_count === "number") { + result.rows = payload.manifest.record_count; + } + + // Key selection reads the SIGNED payload only. The envelope is not covered by + // the signature, so trusting its key_id would let anyone redirect which key + // is used to check the bytes — and then present a document that "verifies". + const keyId = payload.issuer.signing_key_id; + if (!keyId) return finish(result, "MALFORMED", "missing issuer.signing_key_id"); + result.key_id = keyId; + + // A disagreement between the signed payload and the envelope means the + // document is internally inconsistent. Refuse rather than silently + // preferring one, so the condition is visible instead of papered over. + if (env.envelope_key_id && env.envelope_key_id !== keyId) { + return finish( + result, + "MALFORMED", + `envelope names key_id ${env.envelope_key_id} but the signed payload names ${keyId}`, + ); + } + + const key = findKey(trustedKeys, keyId); + if (!key || key.revoked_at || !isEd25519(key.algorithm)) { + result.checks.key_trust = "fail"; + const reason = !key + ? `key_id ${keyId} not in trusted keys` + : key.revoked_at + ? `key_id ${keyId} was revoked at ${key.revoked_at}` + : `key ${keyId} is not ed25519`; + return finish(result, "UNKNOWN_KEY", reason); + } + result.checks.key_trust = "pass"; + result.key_label = key.label; + + const sigBytes = decodeSignature(signature); + if (!sigBytes) { + return finish(result, "MALFORMED", "signature is not 64 bytes of base64-encoded Ed25519"); + } + + // v2 signs the entire payload — nothing is stripped. + const canonicalBytes = canonicalizeToBytes(payload); + const publicKey = createPublicKey({ key: pemFromRawEd25519(key.public_key), format: "pem" }); + const sigOk = cryptoVerify(null, canonicalBytes, publicKey, sigBytes); + result.checks.signature = sigOk ? "pass" : "fail"; + if (!sigOk) { + return finish( + result, + "INVALID", + "ed25519 signature does not verify against canonicalized cert.v2 payload", + ); + } + + if (datasetPath) { + const actualHex = await sha256File(datasetPath); + result.dataset_hash_actual = formatDigest(actualHex); + if (actualHex !== payload.artifact_hash.toLowerCase()) { + result.checks.dataset_match = "fail"; + return finish( + result, + "DATASET_MISMATCH", + `artifact hash mismatch (expected sha256:${payload.artifact_hash.toLowerCase()}, got ${result.dataset_hash_actual})`, + ); + } + result.checks.dataset_match = "pass"; + } + + return finish(result, "VALID", "signature verified and key is trusted"); +} + +/** + * Compared case-insensitively on purpose. The published keys document spells + * this "Ed25519"; this verifier's own fixtures spell it "ed25519". Treating + * that as an untrusted key would report UNKNOWN_KEY — a security verdict — for + * a cosmetic difference, which teaches users to disbelieve the tool. + */ +function isEd25519(algorithm: string | undefined): boolean { + return typeof algorithm === "string" && algorithm.toLowerCase() === "ed25519"; +} + +function validateV2Shape(p: CertV2Payload): string | null { + if (!p || typeof p !== "object") return "cert.v2 payload is not an object"; + for (const f of ["certificate_id", "issued_at", "artifact_hash", "issuer"] as const) { + if (p[f] === undefined || p[f] === null) return `missing required field: ${f}`; + } + if (!p.issuer || typeof p.issuer !== "object") return "issuer must be an object"; + if (typeof p.artifact_hash !== "string" || !/^[0-9a-f]{64}$/i.test(p.artifact_hash)) { + return "artifact_hash must be 64 hex characters"; + } + if (p.hash_method && !/^sha-?256$/i.test(p.hash_method)) { + return `unsupported hash_method: ${p.hash_method}`; + } + return null; +} + +function decodeSignature(b64: string): Buffer | null { + try { + const buf = Buffer.from(b64, "base64"); + if (buf.length !== 64) return null; + return buf; + } catch { + return null; + } +} + +function pemFromRawEd25519(material: string): string { + if (material.includes("BEGIN PUBLIC KEY")) return material; + const raw = Buffer.from(material, "base64"); + if (raw.length !== 32) throw new Error(`expected 32-byte ed25519 key, got ${raw.length}`); + const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); + const der = Buffer.concat([spkiPrefix, raw]).toString("base64"); + return `-----BEGIN PUBLIC KEY-----\n${der.match(/.{1,64}/g)!.join("\n")}\n-----END PUBLIC KEY-----\n`; +} + +function blankV2Result(): VerifyResult { + return { + verdict: "MALFORMED", + certification_id: null, + key_id: null, + issuer: null, + algorithm: null, + signed_at: null, + dataset_hash_expected: null, + dataset_hash_actual: null, + checks: { signature: "skipped", key_trust: "skipped", dataset_match: "skipped" }, + reason: "", + }; +} + +function finish(r: VerifyResult, verdict: VerifyResult["verdict"], reason: string): VerifyResult { + r.verdict = verdict; + r.reason = reason; + return r; +} diff --git a/src/cli.ts b/src/cli.ts index 6895598..7ed17a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,9 +2,10 @@ import { fetchCert } from "./fetch-cert.js"; import { loadKeys } from "./keys.js"; import { verifyCertificate } from "./verify.js"; +import { isCertV2, verifyCertificateV2 } from "./cert-v2.js"; import { fetchReceipt, loadReceiptKey, verifyReceiptEnvelope, type ReceiptVerifyResult } from "./receipt.js"; import { resolveArtifactKind, type ArtifactKind } from "./resolve.js"; -import type { VerifyResult } from "./types.js"; +import type { Certificate, VerifyResult } from "./types.js"; interface CliArgs { positional: string[]; @@ -142,12 +143,17 @@ export async function main(argv: string[]): Promise { } } - // ── Certificate path (unchanged behavior) ────────────────────────────── + // ── Certificate path ─────────────────────────────────────────────────── + // cert.v1 and cert.v2 sign different bytes and name the signer in different + // places, so the schema decides which verifier runs. Both return the same + // VerifyResult, so output and exit codes are identical either way. let result: VerifyResult; try { - const cert = await fetchCert(target, { offline: args.offline }); + const doc = await fetchCert(target, { offline: args.offline }); const keys = await loadKeys({ keysFile: args.keys, offline: args.offline, noCache: args.noCache }); - result = await verifyCertificate(cert, keys, args.dataset); + result = isCertV2(doc) + ? await verifyCertificateV2(doc, keys, args.dataset) + : await verifyCertificate(doc as unknown as Certificate, keys, args.dataset); } catch (err) { const reason = (err as Error).message; const isNetwork = /failed to fetch|HTTP \d|ENOTFOUND|ECONN|getaddrinfo/i.test(reason); @@ -215,9 +221,16 @@ function printHuman(r: VerifyResult): void { process.stdout.write(`${c.green("✓ VALID")} certification_id ${id}\n`); const label = r.key_label ? `${r.key_id} (${r.issuer}, ${r.key_label})` : `${r.key_id} (${r.issuer})`; process.stdout.write(` ${c.dim("signed by")} ${label}\n`); - const rows = (r.rows ?? 0).toLocaleString("en-US"); - const cols = (r.columns ?? 0).toLocaleString("en-US"); - process.stdout.write(` ${c.dim("algorithm")} ${r.algorithm} · ${rows} rows × ${cols} cols · signed ${r.signed_at}\n`); + // cert.v2 carries no column count, so only print a shape when we have + // one. Printing "0 rows × 0 cols" for a v2 certificate states something + // false about the artifact. + const shape = + r.rows !== undefined && r.columns !== undefined + ? `${r.rows.toLocaleString("en-US")} rows × ${r.columns.toLocaleString("en-US")} cols · ` + : r.rows !== undefined + ? `${r.rows.toLocaleString("en-US")} records · ` + : ""; + process.stdout.write(` ${c.dim("algorithm")} ${r.algorithm} · ${shape}signed ${r.signed_at}\n`); if (r.checks.dataset_match === "pass") { process.stdout.write(` ${c.dim("dataset")} ${r.dataset_hash_actual} ${c.green("matches")}\n`); } diff --git a/src/fetch-cert.ts b/src/fetch-cert.ts index 7f0dc20..4ff1f1e 100644 --- a/src/fetch-cert.ts +++ b/src/fetch-cert.ts @@ -1,18 +1,46 @@ import { readFile } from "node:fs/promises"; -import type { Certificate } from "./types.js"; -export const DEFAULT_CERT_API = "https://certifieddata.io/api/v1/certificates"; +/** + * Resolving a bare certificate id must land on bytes that are actually + * verifiable. + * + * `https://certifieddata.io/api/v1/certificates/:id` — the path this used to + * point at — returns 404; it was never deployed. + * + * `https://api.certifieddata.io/api/certificates/:id` does respond, but it + * serves a cert.v1-shaped *display projection* of a cert.v2 certificate. It + * carries the real signature bytes while the signature covers the v2 payload, + * not the projection, so verifying that document yields INVALID — which reads + * as tampering when nothing has been tampered with. + * + * `/signed-payload` is the envelope whose signature verifies over its own + * payload, so that is what a verifier has to ask for. + */ +export const DEFAULT_CERT_API = "https://api.certifieddata.io/api/certificates"; +export const CERT_ENVELOPE_SUFFIX = "/signed-payload"; export interface FetchCertOptions { apiBase?: string; offline?: boolean; } -export async function fetchCert(idOrPathOrUrl: string, opts: FetchCertOptions = {}): Promise { +/** + * Returns the raw certificate document. Shape validation belongs to the + * verifier, not the fetcher: the document may be a cert.v1 certificate or a + * cert.v2 envelope, and deciding which is the caller's job. + */ +export async function fetchCert( + idOrPathOrUrl: string, + opts: FetchCertOptions = {}, +): Promise> { if (idOrPathOrUrl === "-") { return parseCertJson(await readStdin()); } - if (idOrPathOrUrl.endsWith(".json") || idOrPathOrUrl.startsWith("./") || idOrPathOrUrl.startsWith("/")) { + if ( + idOrPathOrUrl.endsWith(".json") || + idOrPathOrUrl.startsWith("./") || + idOrPathOrUrl.startsWith("/") + ) { return parseCertJson(await readFile(idOrPathOrUrl, "utf8")); } if (/^https?:\/\//.test(idOrPathOrUrl)) { @@ -23,7 +51,7 @@ export async function fetchCert(idOrPathOrUrl: string, opts: FetchCertOptions = throw new Error("cannot resolve certification id in --offline mode (pass a local file)"); } const base = opts.apiBase ?? DEFAULT_CERT_API; - const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}`; + const url = `${base.replace(/\/$/, "")}/${encodeURIComponent(idOrPathOrUrl)}${CERT_ENVELOPE_SUFFIX}`; return parseCertJson(await fetchText(url)); } @@ -33,14 +61,14 @@ async function fetchText(url: string): Promise { return res.text(); } -function parseCertJson(body: string): Certificate { - return JSON.parse(body) as Certificate; +function parseCertJson(body: string): Record { + return JSON.parse(body) as Record; } async function readStdin(): Promise { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk as Buffer); + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)); } return Buffer.concat(chunks).toString("utf8"); } diff --git a/src/index.ts b/src/index.ts index ef75e1d..dbd51a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ export { verifyCertificate } from "./verify.js"; +export { verifyCertificateV2, isCertV2, toEnvelope } from "./cert-v2.js"; +export type { CertV2Payload, CertV2Envelope, CertV2Signature } from "./cert-v2.js"; export { canonicalize, canonicalizeToBytes } from "./canonicalize.js"; export { sha256Hex, sha256File, formatDigest, parseDigest } from "./hash.js"; export { loadKeys, findKey, DEFAULT_KEYS_URL } from "./keys.js"; -export { fetchCert, DEFAULT_CERT_API } from "./fetch-cert.js"; +export { fetchCert, DEFAULT_CERT_API, CERT_ENVELOPE_SUFFIX } from "./fetch-cert.js"; export type { Certificate, KeyDoc, diff --git a/src/keys.ts b/src/keys.ts index 8595384..ce8c4d7 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -3,7 +3,19 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import type { KeyDoc, KeyEntry } from "./types.js"; -export const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/certifieddata-keys.json"; +/** + * The pinned trust root. + * + * This is the document the issuer actually publishes, and the one every + * certificate's own `public_key_url` points at. It is pinned here rather than + * read out of the certificate: a URL taken from an unverified document would + * let whoever supplied the document choose the keys it is checked against. + * + * `certifieddata-keys.json` — the path this used to point at — returns 404 and + * was never deployed. Both dialects are accepted by `parseKeyDoc`, so if that + * document is published later it will work without a code change. + */ +export const DEFAULT_KEYS_URL = "https://certifieddata.io/.well-known/signing-keys.json"; export const CACHE_PATH = join(homedir(), ".certifieddata", "keys.json"); export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -57,12 +69,83 @@ export function findKey(doc: KeyDoc, keyId: string): KeyEntry | undefined { return doc.keys.find((k) => k.key_id === keyId); } +/** + * Two dialects are in circulation and both have to work: + * + * this verifier's own shape keys[].public_key, algorithm "ed25519", + * per-key revoked_at + * signing-keys.v1 (published) keys[].public_key_pem, algorithm "Ed25519", + * revocation in top-level revoked[]/retired[] + * + * Normalizing here rather than at each call site means the difference cannot + * turn into a wrong verdict. Two of these differences are security-relevant: + * a mis-read `algorithm` yields UNKNOWN_KEY on a good key, and an unmapped + * `revoked[]` would let a revoked key keep verifying. + */ function parseKeyDoc(body: string): KeyDoc { - const parsed = JSON.parse(body) as KeyDoc; + const parsed = JSON.parse(body) as Record; if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.keys)) { throw new Error("invalid key document: missing keys[]"); } - return parsed; + + const revokedAtFor = buildRevocationIndex(parsed); + + const keys: KeyEntry[] = (parsed.keys as Record[]).map((raw) => { + const keyId = String(raw.key_id ?? ""); + const material = (raw.public_key ?? raw.public_key_pem ?? raw.public_key_raw_b64url) as + | string + | undefined; + if (!keyId || typeof material !== "string" || material.length === 0) { + throw new Error(`invalid key document: entry ${keyId || "(no key_id)"} has no public key`); + } + return { + ...(raw as unknown as KeyEntry), + key_id: keyId, + // CRLF appears in the published PEM; node's createPublicKey is fussier + // about that than it needs to be. + public_key: material.replace(/\r\n/g, "\n"), + algorithm: String(raw.algorithm ?? "").toLowerCase() as "ed25519", + revoked_at: (raw.revoked_at as string | null | undefined) ?? revokedAtFor.get(keyId) ?? null, + }; + }); + + return { ...(parsed as unknown as KeyDoc), issuer: String(parsed.issuer ?? ""), keys }; +} + +/** + * signing-keys.v1 lists revocations separately from the key entries. Entries + * may be bare key_id strings or objects; anything else is refused rather than + * ignored, because silently skipping a revocation record we cannot read would + * mean treating a possibly-revoked key as good. + */ +function buildRevocationIndex(doc: Record): Map { + const out = new Map(); + for (const field of ["revoked", "retired"] as const) { + const list = doc[field]; + if (list === undefined || list === null) continue; + if (!Array.isArray(list)) { + throw new Error(`invalid key document: ${field} must be an array`); + } + for (const entry of list) { + if (typeof entry === "string") { + out.set(entry, `listed in ${field}[]`); + continue; + } + if (entry && typeof entry === "object") { + const e = entry as Record; + const id = e.key_id ?? e.id; + if (typeof id === "string" && id.length > 0) { + const when = e.revoked_at ?? e.retired_at ?? e.at; + out.set(id, typeof when === "string" ? when : `listed in ${field}[]`); + continue; + } + } + throw new Error( + `invalid key document: unreadable entry in ${field}[] — refusing to ignore a revocation record`, + ); + } + } + return out; } async function readCacheIfFresh(path: string): Promise { diff --git a/src/resolve.ts b/src/resolve.ts index ef7b7e1..3194df9 100644 --- a/src/resolve.ts +++ b/src/resolve.ts @@ -31,7 +31,7 @@ export async function resolveArtifactKind( // Endpoint-URL hints. if (/\/api\/payments\/verify\//.test(target)) return { kind: "receipt", via: "url-path" }; - if (/\/api\/v1\/certificates\//.test(target)) return { kind: "certificate", via: "url-path" }; + if (/\/api\/(v1\/)?certificates?\//.test(target)) return { kind: "certificate", via: "url-path" }; // Local file / stdin: sniff the JSON shape. const looksLocal = @@ -43,11 +43,26 @@ export async function resolveArtifactKind( if (looksLocal && target !== "-") { try { const parsed = JSON.parse(await readFile(target, "utf8")) as Record; + // A cert.v2 envelope names its schema on the payload, not at the top + // level — production serves the outer document as + // "certifieddata.manifest.v1" — so sniffing only the top level would + // miss every real certificate. const schema = (parsed.schema_version as string | undefined) ?? - ((parsed.receipt as Record | undefined)?.schema_version as string | undefined); + ((parsed.payload as Record | undefined)?.schema_version as + | string + | undefined) ?? + ((parsed.receipt as Record | undefined)?.schema_version as + | string + | undefined); + const payloadSchema = (parsed.payload as Record | undefined) + ?.schema_version as string | undefined; if (schema === "payment_receipt.v1") return { kind: "receipt", via: "local-schema" }; - if (typeof schema === "string" && schema.startsWith("cert.")) { + if ( + isCertificateSchema(schema) || + isCertificateSchema(payloadSchema) || + schema === "certifieddata.manifest.v1" + ) { return { kind: "certificate", via: "local-schema" }; } // Envelope shape without schema — a verify-endpoint dump. @@ -90,3 +105,18 @@ export async function resolveArtifactKind( if (rcpt === "exists") return { kind: "receipt", via: "probe" }; return { kind: "not_found", via: "probe" }; } + +/** + * Certificate schema names seen in the wild: + * cert.v1, cert.v2 — the payload's own schema_version + * certifieddata.cert.v1 — the public display projection + * certifieddata.manifest.v1 — the signed-payload envelope + */ +function isCertificateSchema(schema: string | undefined): boolean { + if (typeof schema !== "string") return false; + return ( + schema.startsWith("cert.") || + schema.startsWith("certifieddata.cert.") || + schema.startsWith("certifieddata.manifest.") + ); +}