Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
43 changes: 40 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<id>` 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.
73 changes: 63 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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 <path>` 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/<id>/signed-payload`.

Use that one. `…/api/certificates/<id>` (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

Expand Down Expand Up @@ -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.
Expand All @@ -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`.

Expand Down
48 changes: 48 additions & 0 deletions dist/cert-v2.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>): CertV2Envelope | string;
export declare function verifyCertificateV2(doc: Record<string, unknown>, trustedKeys: KeyDoc, datasetPath?: string): Promise<VerifyResult>;
//# sourceMappingURL=cert-v2.d.ts.map
1 change: 1 addition & 0 deletions dist/cert-v2.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading