← All posts
Engineering CloakAPI · 2026-04-29 · 9 min read

Verifying receipts without trusting us

Privacy claims are easy to make. "We don't see your data" appears in dozens of vendor marketing pages. The problem is that the claim is unfalsifiable: without a cryptographic proof, your auditor has to take the vendor's word for it. CloakAPI's signed-receipt system changes that equation. This post walks through exactly how it works — including code you can run today.

Updated July 2026 — OpenReceipt v3 Receipts are now client-signed before egress: the desktop app, browser extension, or in-browser engine that performs tokenisation signs the receipt with a locally generated ECDSA P-256 key, and the gateway adds an additive countersignature for the relay facts it alone can see. The canonical wire format is OpenReceipt v3 ("v":"v3", alg ecdsa-p256-sha256). The verification recipe below reflects v3; legacy v1/v2 receipts still verify under the rules in §4.0 of the spec.

The trust problem with privacy claims

When a vendor says "we don't retain your prompts," you have no mechanism to verify it. You can read their privacy policy, ask their sales team, or hire a penetration tester — but none of those approaches produce cryptographic evidence. At best, you get a point-in-time audit report, which proves controls were in place during a sample period, not that any specific request was handled correctly.

What you actually need is a tamper-evident, per-request audit trail that you can inspect independently. That is what OpenReceipt provides. Every request produces a signed receipt: a small JSON envelope recording what happened, signed on the device that performed tokenisation before any byte leaves it, then countersigned by the gateway for the relay facts it can attest to, and chained to every previous receipt so any gap is detectable.

You do not need a CloakAPI account to verify a receipt. You do not need to call our API. A v3 receipt is self-contained — it embeds the client's public key — so you need the receipt, the gateway JWKS endpoint (for the countersignature), and about thirty lines of Python.

The cryptographic primitives

ECDSA P-256 with SHA-256

Receipts are signed using ECDSA over the NIST P-256 elliptic curve (also called secp256r1 or prime256v1), with SHA-256 as the hash function. The v3 envelope labels this alg as ecdsa-p256-sha256; it is the same primitive that RFC 7518 (the JSON Web Algorithms specification) calls ES256. P-256 is well-supported across all major cryptographic libraries, is FIPS 186-4 compliant, and produces compact 64-byte signatures.

The client's signing key is generated on the device (desktop app, browser extension, or in-browser engine) and never leaves it; its public key travels embedded in the receipt (§8.2 of the spec), so an offline verifier never has to contact anyone to check the client signature. The gateway's countersignature key is held server-side in software — there is no HSM — and its public verification key is published at the JWKS endpoint documented below.

RFC 8785 JSON Canonicalisation Scheme (JCS)

JSON has a serialisation ambiguity problem: the same logical object can be serialised many different ways (key ordering, whitespace, Unicode escaping). You cannot sign raw JSON and expect anyone else to reproduce exactly the same byte sequence to verify the signature.

CloakAPI uses RFC 8785 — the JSON Canonicalisation Scheme (JCS) — to produce a deterministic byte sequence before signing. JCS specifies: keys are sorted lexicographically by Unicode code point; no insignificant whitespace; numbers are serialised with IEEE 754 double-precision semantics; strings use minimal Unicode escaping. The output is always valid UTF-8.

In v3 the signed message is the JCS-canonicalised form of the entire envelope with the sig field removed. The sig is the raw r || s concatenation (64 bytes), base64url-encoded without padding — DER encoding is explicitly rejected. Deterministic nonces per RFC 6979 are recommended so fixtures are byte-stable. (The legacy v1/v2 envelopes used JOSE/DER shapes; a conformant v3 verifier dispatches on the v and alg fields and accepts all three.)

The JWKS endpoint

CloakAPI publishes its current signing key set at:

GET https://api.cloakapi.io/api/.well-known/cloakapi-receipt-pubkeys.jwks

The response is a standard JWK Set (RFC 7517) containing the gateway's EC public keys with "crv": "P-256" and "alg": "ES256". Each key carries a kid (key ID). The countersignature in a receipt names its kid so you know which public key to use without iterating the entire set. The currently-active key is flagged with "x-cloakapi-active": true; superseded keys stay published so historical countersignatures keep verifying, and a fresh copy is cheap to re-fetch.

An example key from the live JWKS (the client's own signing key, by contrast, is embedded in each v3 receipt and is not published here):

{
  "keys": [
    {
      "kty": "EC",
      "crv": "P-256",
      "alg": "ES256",
      "use": "sig",
      "kid": "gw-prod-2026-q2",
      "key_ops": ["verify"],
      "x-cloakapi-active": true,
      "x": "4pSfa_TppW5WfEkgUuqkMqGhFJ9HXF0OvH-9sIT0vPE",
      "y": "xcvkfoFM3TFWjVTucGcbNKnEbJxmGn8gwEZy9Sbh-N8"
    }
  ]
}

A v3 receipt envelope

A client-signed, gateway-countersigned v3 envelope looks like this (fields abbreviated):

{
  "v":   "v3",
  "alg": "ecdsa-p256-sha256",
  "kid": "device-key-01JZQ7M3F8...",
  "iss": "client",
  "iat": "2026-04-29T11:03:22.417Z",
  "jti": "01HZ9QK4WMFVG3C8XSDQVT7R4P",
  "chain": { "prev_hash": null },
  "claims": {
    "v3-attestation": {
      "tier":                   "gateway-countersigned",
      "no_raw_pii_egress":      true,
      "tokenised_body_hash":    "sha256:7f4e3b2a...(64 hex chars)...",
      "detector_categories":    ["NAME", "MRN", "DATE"],
      "engine_ruleset_version": "v1.4.2",
      "engine_binary_hash":     "sha256:c1a9...",
      "genuine_engine":         true,
      "privacy_tier":           "balanced",
      "token_binding_hmac":     "...(commits to token↔PII bindings, reveals nothing)...",
      "anchoring":              "beacon",
      "attester_public_key":    "04<X><Y>  (SEC1 uncompressed P-256, 130 hex chars)"
    },
    "countersignature": {
      "iss":      "https://api.cloakapi.io",
      "kid":      "gw-prod-2026-q2",
      "provider": "openai",
      "sig":      "...gateway r||s, base64url..."
    }
  },
  "sig": "...client r||s, base64url (64 bytes)..."
}

Two signatures, two parties. The client signature (sig at the top level) is computed by the tokenising client over JCS(envelope without sig) and is checked against the attester_public_key embedded in the receipt — no lookup required. The gateway countersignature (under claims.countersignature) witnesses the relay facts the gateway alone can see (which provider it forwarded to) and is checked against the JWKS key named by its kid. Crucially, the attestation carries no raw PII: only hashes, category names, and an HMAC that commits to the token↔PII bindings without revealing them.

Verifying offline with the Python reference verifier

The OpenReceipt project publishes a reference verifier. Its only runtime dependency is the cryptography package from PyCA. See signedreceipts.org/implementations for the source bundle.

The core of the v3 client-signature check needs no network at all — the signing key is embedded in the receipt:

import json, base64
from cryptography.hazmat.primitives.asymmetric.ec import (
    ECDSA, SECP256R1, EllipticCurvePublicKey
)
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.exceptions import InvalidSignature

def jcs(obj):
    """RFC 8785: sorted keys, no whitespace, minimal escaping."""
    return json.dumps(obj, sort_keys=True, separators=(',', ':'),
                      ensure_ascii=False).encode('utf-8')

def verify_client_sig(receipt: dict) -> bool:
    # v3: the client signing key is embedded (SEC1 uncompressed hex, "04"+X+Y)
    hexpk = receipt['claims']['v3-attestation']['attester_public_key']
    pub = EllipticCurvePublicKey.from_encoded_point(
        SECP256R1(), bytes.fromhex(hexpk)
    )
    # Signed message = JCS of the envelope with the top-level "sig" removed
    env = {k: v for k, v in receipt.items() if k != 'sig'}
    msg = jcs(env)
    # sig is raw r||s (64 bytes), base64url without padding -> DER for PyCA
    raw = base64.urlsafe_b64decode(receipt['sig'] + '==')
    r = int.from_bytes(raw[:32], 'big')
    s = int.from_bytes(raw[32:], 'big')
    try:
        pub.verify(encode_dss_signature(r, s), msg, ECDSA(SHA256()))
        return True
    except InvalidSignature:
        return False

The gateway countersignature is checked with the identical math, except the message is JCS(countersignature without its own sig) and the key comes from the JWKS entry named by claims.countersignature.kid (reconstructed from its x/y coordinates). Fetch the JWKS once, cache it, and both signatures verify without ever contacting us again.

Usage from the command line (download the verifier bundle from signedreceipts.org/implementations):

pip install cryptography
python verify.py path/to/receipt.json

The verifier reports VALID or INVALID: <reason> for each receipt file. Because v3 receipts embed the client key, the client signature verifies fully offline; pass --offline /path/to/jwks.json to also check countersignatures without any network call — useful for air-gapped environments.

Chain-link discipline: the chain.prev_hash field

Each v3 envelope carries a chain object whose prev_hash is the SHA-256 hash of the JCS-canonicalised immediately-preceding receipt in the same chain. This creates a forward-linked chain. The first receipt in a chain uses prev_hash: null.

The chain-link property means: if someone deletes a receipt from the middle of your audit log, the hash in the next receipt will no longer match, and verification will fail. You cannot silently remove a receipt without breaking every subsequent receipt in the chain.

The verifier checks chain integrity automatically when you pass a directory of receipts:

python verify.py --chain receipts/

It sorts by iat timestamp, walks the chain, and reports any break — including gaps (missing receipt files) and hash mismatches (tampered envelopes).

A few practical notes on chain discipline:

Honest limitations

Because tokenisation happens on the device and the receipt is signed there before egress, the gateway operates as a pure relay — it never receives the plaintext to begin with, and its countersignature only witnesses the relay facts (which provider it forwarded to). What the receipt cannot do by itself is prove the client engine actually tokenised correctly. That trust is shifted onto the client attestation: genuine_engine, engine_binary_hash, and no_raw_pii_egress are what the device signs, and a tampered or bypassed engine is what a verifier would look for.

So the full guarantee is receipts plus a genuine client engine. Receipts prove integrity, non-repudiation, and the attested no-raw-PII-egress claim; the on-device tokeniser is what makes that claim true in the first place. Both are necessary; neither is sufficient alone.

Additionally, checking a countersignature means trusting the JWKS endpoint that serves the gateway's public key. If an attacker controlled it, they could serve a forged key. Mitigation: cache your JWKS snapshots and compare against the live endpoint over time — and, because each receipt's canonical hash can be anchored in the public transparency log (directly for countersigned receipts, or via a content-free beacon), you have an independent record to compare against. The client signature itself needs no such trust: its key is embedded and self-contained.

Call to action

The OpenReceipt specification and Python reference verifier are open source and MIT-licensed. You can:

If you are a CloakAPI customer, your account exports receipts in bulk or individually — see the receipts documentation. If you are an auditor reviewing a customer's CloakAPI usage, ask for a receipt export — you can verify everything listed here without contacting us.

Reference RFC 7517 (JWK), RFC 7518 (JWA / ES256), RFC 8785 (JCS), RFC 6979 (deterministic ECDSA), FIPS 186-4 (P-256), NIST SP 800-186.

Verify your own receipts

Download the OpenReceipt verifier bundle and run the reference verifier against a real receipt export from your account.

Get the verifier at signedreceipts.org