Skip to content

fix: the verifier could not verify any production certificate (cert.v2 + trust root) - #8

Merged
dkitchell merged 1 commit into
mainfrom
fix/cert-v2
Sep 14, 2026
Merged

dkitchell merged 1 commit into
mainfrom
fix/cert-v2

Conversation

@dkitchell

Copy link
Copy Markdown
Contributor

Closes the certificate path. No production deploy is required — see "What this does not need" below.

What was broken

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). Separately the pinned keys URL 404'd, so verification exited NETWORK before reaching a signature.

The cryptography was fine

Signed bytes are Ed25519(JCS(payload)). Confirmed empirically against the live production certificate now committed as fixtures/valid-cert-v2.json — of four candidate constructions, exactly one verifies:

  VERIFIES  JCS(payload)
  no        JSON.stringify(payload)
  no        JCS(envelope minus signature)
  no        JCS(payload minus hashes.certificate_payload_sha256)

So this is schema mapping, not a cryptography change.

Three things the original spec of this work got wrong

Each would have shipped a verifier that still failed on every certificate. All three now have regression tests.

1. signature is an object, not a base64 string.

/api/certificates/:id/signed-payload  ->  { alg, key_id, value }
/api/certificates/:id                 ->  { alg, key_id, sig }

The specified implementation required typeof signature === "string" and would have returned MALFORMED on all 577 certificates — the same failure it set out to fix. Both spellings and a bare string are now accepted.

2. The keys document to trust is /.well-known/signing-keys.json.

It is live, 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, so parseKeyDoc now normalizes both:

this verifier's shape signing-keys.v1 (published)
key material public_key public_key_pem (with CRLF)
algorithm "ed25519" "Ed25519"
revocation per-key revoked_at top-level revoked[] / retired[]

Two of those are security-relevant, not cosmetic:

  • A mis-read algorithm yielded UNKNOWN_KEY — a security verdict — on a good key, for one capital letter. A verifier that cries wolf teaches people to disbelieve it.
  • An unmapped revoked[] would have let a revoked key keep verifying. A revocation entry that cannot be parsed is now an error rather than skipped, so an unreadable revocation record can never be mistaken for a good key.

3. Resolving a bare id must fetch /signed-payload.

The plain /api/certificates/:id response is a certifieddata.cert.v1-shaped display projection. It carries the real signature bytes (base.signature.sig === signed.signature.value), but the signature covers the v2 payload, not the projection — so verifying it returns INVALID on an untampered certificate. DEFAULT_CERT_API now targets the envelope.

Key selection is treated as a security boundary

Key selection reads the signed payload only (payload.issuer.signing_key_id). The envelope is not covered by the signature, so trusting its key_id would let whoever supplied a document choose which key it is checked against. A disagreement between envelope and payload is MALFORMED rather than silently resolved in either direction.

Verified against production

$ node dist/cli.js fixtures/valid-cert-v2.json --keys fixtures/prod-keys.json --offline
✓ VALID  certification_id d6da041f-a70c-4945-93b7-dff1e42a00d0
  signed by  ed25519-prod-2025-02  (Certified Data LLC, production signing key)

$ node dist/cli.js fixtures/valid-cert-v2.json --no-cache          # fetches live keys
✓ VALID

$ node dist/cli.js d6da041f-a70c-4945-93b7-dff1e42a00d0 --type certificate --no-cache
✓ VALID                                                             # no --keys, no --offline

Regressions both still pass:

$ node dist/cli.js fixtures/valid-cert.json --keys fixtures/keys.json --offline   # cert.v1
✓ VALID
$ node dist/cli.js fixtures/valid-receipt.json --type receipt --json             # receipt
{"verdict":"VALID",…}

93 tests pass (was 71), typecheck clean, lint unchanged apart from the one pre-existing unused-directive warning. verify:dist passes.

What this does not need

The plan for this work budgeted a keys-document deploy and a new /api/v1/certificates/:id endpoint. Neither is required: pointing at the document the issuer already publishes, and at the envelope endpoint that already exists, closes the path with zero production changes. Worth noting /api/v1/ would also have been a third URL convention — this repo's canonical prefix is /v1/<name>, not /api/v1/<name>.

Also in here

  • .github/workflows/publish.yml would have failed on auth: it requested --provenance with no NODE_AUTH_TOKEN, and Node 22 bundles npm 10.9.x where OIDC trusted publishing needs ≥ 11.5.1. Adds an npm upgrade step, a token fallback, and a tag-vs-version guard.
  • README: corrected the keys-document URL, the "issues cert.v1" claim, the offline curl example (it fetched a 404), and added the v1/v2 table, the envelope shape, and the display-projection warning.
  • hashes.certificate_payload_sha256 is documented as non-normative. Its published value is not reproducible under JCS, JSON.stringify, sorted-key stringify, or pretty-printed JSON (claimed d611a011…, JCS gives 1beed455…) — most likely computed over insertion-ordered JSON, which Postgres jsonb does not preserve. The signature is unaffected.

Two things for a human to decide

  1. fixtures/valid-cert-v2.json is a real production certificate in a public repo. It contains subject.user_id and a dataset filename. That data is already public — the endpoint it came from is unauthenticated — so this publishes nothing new. But if you'd rather not have a customer's user_id in the repo, the fix is a dedicated canary certificate issued for this purpose, and I'd swap it.
  2. Publishing to npm is not done here. npm publish is a human action and irreversible for a given version.

Found while doing this, not fixed here

/api/certificates/:id/download and /signed-payload share a helper that does not filter on certificate status, while /api/certificates/:id does (status = 'ISSUED'). So a draft or revoked certificate's signed payload is publicly retrievable and this verifier will report it VALID — correctly, since the signature is genuine. That is a platform-side issue, not a verifier one; filing separately.

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 <noreply@anthropic.com>
@dkitchell

Copy link
Copy Markdown
Contributor Author

Fixture question resolved — no swap needed.

Queried public.users directly now that DB access is available:

536f5e7e-7c9d-4f25-ba3a-319230b0c6cf  ->  dkitchell@uspaymentnetwork.com

That is Drew's own account, and it holds 507 of the certificates. So fixtures/valid-cert-v2.json is already the canary the swap was meant to produce: issued to Drew's own account, through the real pipeline, with the real production signing key. There is no customer payload in this repo.

For completeness, the other two ids checked: 3f313cf2… (SEED_USER_ID) and 9f31ee72… (resorttown@gmail.com) — neither is the fixture's subject.

No branch rewrite is needed and this PR can merge as-is.

@dkitchell
dkitchell merged commit 659c66e into main Sep 14, 2026
6 checks passed
@dkitchell
dkitchell deleted the fix/cert-v2 branch September 14, 2026 23:50
dkitchell added a commit that referenced this pull request Sep 15, 2026
…enance it cannot have (#9)

Two reasons a release-triggered publish would have failed.

1. Wrong secret name. The workflow read `secrets.NPM_TOKEN`. The secret that
   actually exists on this repo is `NPM_ACCESS_TOKEN` (set 2026-08-21), so
   NODE_AUTH_TOKEN resolved to an empty string and npm would have published as
   an anonymous client. I introduced this name in #8 without checking what was
   configured.

2. `--provenance` cannot work on a first publish. Trusted publishing is
   configured per package on npmjs.com, which requires the package to already
   exist — and @certifieddata/verify has never been published, so there is
   nothing to configure it against. On top of that, a classic automation token
   combined with --provenance is exactly the combination npm is restricting
   (https://gh.io/npm-gat-bypass2fa-deprecation); the local CLI now warns about
   it on every command.

Dropped --provenance for 0.1.0 and pointed the token at the right secret. Once
0.1.0 is on the registry, trusted publishing can be enabled for the package and
--provenance added back — at which point the token should be deleted rather
than kept alongside it.

The version-vs-tag guard and the lint/typecheck/test gates are unchanged, so a
release still cannot publish a version nobody asked for.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant