Skip to content

Add independent Agent Commerce receipt verification to CLI #2

Description

@dkitchell

Goal

Make @certifieddata/verify verify Agent Commerce receipts locally, so the public /verify story can point to a real terminal-based independent verification path instead of relying on the server's verified: true response.

This is intentionally small: extend the existing zero-dependency/audit-friendly verifier to support one more signed artifact type using the same Ed25519 + RFC 8785 JCS contract already used by receipt issuance.

Related platform integrity issue: certifieddata/certifieddata-platform#315.

Current state

The CLI is certificate-only today:

  • src/cli.ts says Verify a CertifiedData.io certificate.
  • any bare id is resolved through https://certifieddata.io/api/v1/certificates/<id>
  • VerifyResult is certificate-shaped
  • key loading is certificate-key-document specific

The platform already has the required receipt surfaces:

  • public receipt payload: GET /api/payments/verify/:receiptId
  • Agent Commerce public key: GET /.well-known/certifieddata-public-key.pem
  • receipts are Ed25519-signed over RFC 8785 JCS canonicalization of the receipt payload with the signature omitted

Important: do not trust the server's valid / verified / signatureValid fields. Fetch the receipt payload and public key, then recompute locally.

Design

CLI UX

Preferred:

certifieddata-verify <receipt-id>

and it auto-detects the artifact kind.

Because both certificates and current payment receipts may be UUIDs, UUID alone is not a safe discriminator. Use deterministic resolution:

  1. Strong syntax/path hints first:
    • cert_, scert_, etc. -> existing certificate path
    • receipt URL /api/payments/verify/<id> -> receipt
    • local JSON -> inspect schema/shape (payment_receipt.v1 vs cert.v1)
  2. For an ambiguous bare UUID online, resolve against certificate and receipt public endpoints.
    • exactly one exists -> use it
    • both exist -> fail as ambiguous and require explicit --type
    • neither exists -> not found
    • a transport/server failure must not be silently interpreted as "not found"
  3. Add an explicit override:
certifieddata-verify <id> --type receipt
certifieddata-verify <id> --type certificate

This keeps the normal one-line UX while making ambiguity controllable.

Receipt verification contract

For a receipt:

  1. Fetch GET https://certifieddata.io/api/payments/verify/<receiptId>.
  2. Extract the signed receipt payload and signature.
  3. Fetch https://certifieddata.io/.well-known/certifieddata-public-key.pem.
  4. Remove the signature field from the receipt payload.
  5. RFC 8785 JCS-canonicalize the remaining payload.
  6. Verify the Ed25519 signature locally with Node crypto.
  7. If the response exposes a stored receipt hash, recompute SHA-256 over the same canonical bytes and compare locally.
  8. Report server-side booleans, if present, only as informational metadata; they do not determine the CLI verdict.

The platform signs receipts using canonicalPayloadBytes() in apps/api/src/certs/crypto.ts, which uses RFC 8785 JCS via the canonicalize package after stripping undefined values. Match that contract exactly.

Do not use plain JSON.stringify() as the cryptographic canonical form.

Result shape

Generalize VerifyResult enough to cover both certificate and receipt verification without breaking existing consumers.

Suggested additive fields:

artifact_type: "certificate" | "receipt";
artifact_id: string | null;

For backward compatibility, existing certificate fields (certification_id, dataset hash fields, algorithm, rows, columns) can remain nullable/optional for receipts.

Receipt-specific output should include at least:

{
  "artifact_type": "receipt",
  "artifact_id": "...",
  "verdict": "VALID",
  "key_id": "ed25519-prod-2025-02",
  "checks": {
    "signature": "pass",
    "key_trust": "pass",
    "payload_hash": "pass"
  }
}

Use the existing exit-code philosophy: signature/hash failure is non-zero; key unavailable/unknown is distinct from malformed input/network failure.

Human output

Example:

✓ VALID  receipt f41ef7b0-000f-428b-88a8-07cc2ee7a47d
  signed by   ed25519-prod-2025-02 (CertifiedData.io)
  signature   pass
  payload hash pass
  public key  /.well-known/certifieddata-public-key.pem

The command should make the independence claim explicit in docs:

The receipt is fetched from CertifiedData, but the verdict is not. The CLI verifies the Ed25519 signature locally against the published public key.

/verify platform follow-up

The platform browser verifier currently has a related ambiguity: it treats any bare UUID as a certificate, while payment receipts are also UUIDs. Platform issue #315 should update /verify / verify-client.ts to use the same artifact-kind resolution rules or pass an explicit kind into the verifier.

Do not solve that by minting fake rcpt_ prefixes around existing UUID receipt IDs.

Also note: the browser receipt verifier currently uses JSON.stringify(rest) while receipt issuance signs RFC 8785 JCS bytes. That should be corrected in the platform issue so browser and CLI verification share the same canonicalization contract.

Acceptance criteria

  • existing certificate CLI behavior remains backward compatible
  • certifieddata-verify <known receipt UUID> resolves and verifies the receipt locally
  • --type receipt and --type certificate explicitly override detection
  • receipt signature verification uses Node crypto, not the server verdict
  • receipt canonicalization matches RFC 8785 JCS used by platform issuance
  • receipt payload SHA-256 is recomputed locally when the expected hash is available
  • public key is loaded from /.well-known/certifieddata-public-key.pem
  • 503/missing public key returns a non-success exit and does not silently fall back to server verification
  • tampering with any signed receipt field makes verification fail
  • reordering JSON object keys does not change the verdict
  • altering only the server response booleans does not change the local cryptographic verdict
  • ambiguous IDs fail closed rather than choosing an arbitrary artifact kind
  • README includes a real receipt example and clearly distinguishes server response from local proof

Non-goals

  • no new cryptographic primitive
  • no dependency on the CertifiedData server's verdict
  • no historical receipt mutation
  • no broader Decision Ledger support in this issue unless it falls out trivially from a shared artifact resolver
  • no platform repo changes from this repository

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions