# NoData — Audit Chain Specification

**Version:** 1.0 (April 2026)
**Status:** stable, in production
**Companion docs:** `architecture-and-threat-model.md` · `crypto-primitives.md`

This document specifies the construction, properties, and verification of NoData's tamper-evident audit chain — exactly as it runs in production. Anyone with a receipt and access to `/api/verify` can re-derive the chain and detect tampering.

The math below matches the public source: see `apps/web/src/app/api/verify/route.ts` and `apps/web/src/app/api/_lib/protect-receipt-signer.ts` in the open repo.

---

## 1. The chain in one sentence

Every action a NoData client performs (encrypt, sign, decrypt-batch, KEK-rotation, license-issue, license-revoke, seal-create, seal-bind, seal-open, seal-burn, capsule-scan, attestation-accept, quorum-policy-create, etc.) emits an **event**. Events are appended in strict order to a per-nickname chain. Each event references the receipt-id of its predecessor, and its HMAC binds the predecessor's id into the link.

---

## 2. Event canonical form

An event payload is the following JSON structure, serialized with **canonical JSON** (keys sorted alphabetically, no whitespace, UTF-8). The exact canonicalization in code:

```ts
const canonical = JSON.stringify(
  Object.keys(payload)
    .sort()
    .reduce<Record<string, unknown>>((acc, k) => {
      acc[k] = payload[k];
      return acc;
    }, {}),
);
```

Payload contents are event-type-specific. For example, a `content_signed` event payload includes `content_hash`, `perceptual_hash` (optional, for images), `signer_nickname`, `device_id_hash`, etc. The exhaustive event-type catalog is in `protect-receipt-signer.ts` — over 30 event types in production today.

---

## 3. Event hash

```
event_hash = SHA-256(canonical_payload_utf8)
```

Output is hex-encoded (64 lowercase hex chars). Stored in `nd_protect_receipts.event_hash`.

---

## 4. Chain HMAC — the actual production formula

```
hmacInput = event_hash + "|" + (prev_receipt_id || "") + "|" + created_at
chain_hmac = HMAC-SHA-256(secret, hmacInput)
```

Where:
- `event_hash` is the SHA-256 from §3
- `prev_receipt_id` is the previous receipt's ID for this nickname, or empty string for the first event
- `created_at` is the server-side ISO timestamp string written to the row
- `secret` is the global server-side HMAC secret (see §7)
- `|` is a literal pipe character

The output is 64 hex chars stored in `nd_protect_receipts.chain_hmac`.

This formula is the exact code at `apps/web/src/app/api/verify/route.ts:156-158`. Any third party can reproduce verification by querying the chain segment, recomputing `event_hash` from the canonicalized payload, then asking the server to confirm `chain_hmac` matches.

---

## 5. Receipt ID

Receipt IDs are short, opaque, base36 strings:

```
receipt_id = "nd-rcpt-" + 10 random base36 chars
```

Collision domain: 36^10 ≈ 3.6 × 10^15. Generated client-side in `generateReceiptId()`. Stored as the primary key of `nd_protect_receipts.id`.

---

## 6. Verification math

To verify a single receipt, the public `/api/verify` endpoint runs three independent checks:

### Check 1 — content_hash match
The body's `content_hash` (raw 64-char hex, no `sha256:` prefix) must equal `payload.content_hash` of the row.

### Check 2 — event_hash self-consistency
Re-canonicalize the row's payload, recompute SHA-256, compare against the stored `event_hash`. If they don't match, the row was tampered with at the JSON level.

### Check 3 — chain_hmac chain integrity
Recompute the HMAC using the formula in §4 with the server's secret. If it doesn't match the stored `chain_hmac`, either the row was modified after signing, or the chain was forged. Either is a fatal failure.

### Optional Check 4 — sidecar cross-check
If the caller provides a `.nodatasig` sidecar, every claim in it is compared to the server row: `nickname_match`, `event_hash_match`, `chain_index_match`, `chain_hmac_match`, `content_hash_match`. Any mismatch invalidates the sidecar (but the row remains authoritative).

A receipt is `valid` only when checks 1, 2, and 3 (and 4 if sidecar provided) all pass.

---

## 7. The HMAC secret — what it is and where it lives

Production uses a **single global HMAC secret** named `PROOF_HMAC_SECRET` (or its alias `RECEIPT_HMAC_SECRET`) loaded at server cold-start.

Two storage modes (see `apps/web/src/lib/secrets.ts`):

1. **Env-var fallback**: secret stored as a Vercel environment variable. Plaintext at rest in Vercel's dashboard but encrypted in transit and at-rest in their KV store.

2. **GCP-KMS-wrapped (Level 2 vendor-trust hardening)**: a base64 ciphertext blob (`GCP_KMS_WRAPPED_HMAC`) is stored in Vercel; on cold-start the server decrypts it using a key in our GCP KMS instance. Plaintext never touches Vercel's UI. KMS audit log records every decrypt.

Both modes produce **the same HMAC bytes** — the secret value is identical, only its storage at rest differs. Migration between modes is byte-safe.

Implications:
- We do NOT use per-tenant chain secrets. A single secret signs the whole chain
- We do NOT rotate the secret on a fixed cadence. Any rotation event would require a chain boundary marker (see §10)
- A compromise of the secret would let an attacker forge any link in the chain — which is why rotation requires care and we surface this honestly here, not in a footnote

---

## 8. Network protocol

**Endpoint:** the receipt is created server-side as part of the action that triggered it (encrypt, sign, etc.) — it is not a separate API call. Public verification is at:

```
POST https://www.nodatacapsule.com/api/verify
Content-Type: application/json

{
  "content_hash": "086fb29f1d7eaf2c4b9c3a8e5d1f6072c4a89b3e2f7d09e1a4b6c8d3f5a7b9c1",
  "receipt_id": "nd-rcpt-a1b2c3d4e5",
  "sidecar": { ...optional sidecar JSON... },
  "perceptual_hash": "phash:0123456789abcdef"
}
```

Note: `content_hash` in the API request is **raw 64-char hex** (no `sha256:` prefix). The `.nodatasig` sidecar uses `sha256:<64hex>` format and is parsed by the server.

Response:

```json
{
  "success": true,
  "valid": true,
  "receipt_id": "nd-rcpt-a1b2c3d4e5",
  "event_type": "content_signed",
  "signer_nickname": "demo-signer",
  "signed_at": "2026-04-25T12:34:56.789Z",
  "chain_index": 142,
  "checks": {
    "content_hash_match": true,
    "event_hash_match": true,
    "chain_hmac_match": true,
    "sidecar": null
  },
  "forensic": null,
  "proof_url": "/proof/demo-signer"
}
```

Rate limit: 60 requests per IP-hash per minute. Public, no auth.

---

## 9. Perceptual hash — forensic signal, NOT cryptographic

For image content, an optional `perceptual_hash` (pHash) can be supplied by both signer and verifier. If both are present, the server computes the Hamming distance and reports:

- `perceptual_hash_match: true` — visually-identical content
- `perceptual_hash_match: false` — different content
- `perceptual_distance: <integer>` — bit-distance over the pHash space

This is **a signal, never a legal proof**. It exists to surface "same content, different bytes" cases honestly, e.g., a re-encoded image that lost EXIF but kept visual content. The `forensic` block in the response surfaces this when relevant. See `lib/perceptual-hash.ts` for the algorithm.

---

## 10. Detection of tampering — concrete scenarios

### Scenario 1 — a single event row is altered server-side after the fact
Either `event_hash` no longer matches the canonicalized payload (Check 2 fails), or `chain_hmac` no longer matches the recomputed HMAC (Check 3 fails). Detection: any subsequent `/api/verify` call by anyone holding the original receipt. Time to detect: O(1) on the next verify.

### Scenario 2 — an event is removed from the middle of the chain
The remaining receipts' `prev_receipt_id` references break, and `chain_index` numbering has a gap. A consumer querying the chain segment for any range that includes the gap can detect it.

### Scenario 3 — a receipt is forged from outside (no DB row)
`/api/verify` returns `valid: false`, `reason: 'receipt_not_found'`. Detection: O(1).

### Scenario 4 — server fully compromised, attacker rewrites the chain
Customers' locally-held receipts no longer verify against the server. Detection: any verify of an old client-held receipt fails. Recovery: customer can publish their receipt collection externally; any forensic auditor compares the customer's chain to the server's. The customer wins the dispute by holding receipts the server-rewriter does not have.

### Scenario 5 — secret compromise
An attacker holding the global HMAC secret could forge new links going forward. Detection: not built-in to the protocol. Mitigation: rotation event creates a chain boundary. We surface this risk openly: §7 names the secret's storage and migration path.

---

## 11. Public verification endpoints

All verification endpoints are public (no auth, IP-rate-limited):

| Endpoint | Method | Purpose |
| --- | --- | --- |
| `/api/verify` | POST | Verify a single receipt + content_hash |
| `/api/verify-tree` | POST | Verify a folder Merkle root + manifest |
| `/api/proof/<nickname>` | GET | List public receipts for a signer nickname |
| `/api/proof-certificate` | GET | Generate a printable HTML certificate |

Rate limits are per-IP-hash, with generous bucket sizes for auditors and journalists. Exceeding the limit returns 429 with a Retry-After.

---

## 12. Frequently challenged points

### "If you hold the chain secret, you can forge anything."
Yes — *for events not yet issued*. After a receipt is issued and the customer holds a copy, we cannot rewrite that copy. The protection is *append-only-ness witnessed widely*, not *secret-protection*. This is the same trust model as Certificate Transparency logs (RFC 6962): the operator is trusted to append in order, and is trusted with content-binding, but is *not* trusted with secrecy guarantees about content — and any tampering is publicly detectable.

### "Why HMAC and not signatures?"
Both, and they do different jobs. The chain *link* is HMAC-SHA-256: fast, simple key management, and post-quantum safe. The receipt itself and the hourly epoch root are additionally **Ed25519-signed**, and each receipt carries `signing_pubkey_hex` inline while the active key is published at `/api/chain/pubkey`. That is what makes verification possible offline, with standard libraries, without NoData in the loop. HMAC alone would have left every verifier dependent on us.

This applies to the operator and decision receipt chains. Content-signature sidecars (`.nodatasig`, issued by `/sign`) are still linked with HMAC only, so verifying a sidecar's chain position does require a server roundtrip.

### "How do you prevent replay of an old receipt as a new one?"
Each receipt is a row in `nd_protect_receipts` with a strict primary key. The protocol does not append the same receipt twice: each event creates a new row with a new ID and a new HMAC binding the event's `created_at`.

### "What stops you from claiming you serialized correctly when you didn't?"
Nothing, *at the moment of append*. But the resulting chain is a permanent public record. Any inconsistency is detectable by any party holding two receipts and querying the public verify endpoint. The operator cannot retroactively "fix" an out-of-order serialization — the HMACs change and existing receipts fail to verify.

---

*Source: this spec is verifiable against `apps/web/src/app/api/_lib/protect-receipt-signer.ts` and `apps/web/src/app/api/verify/route.ts` in the [nodatachat-core](https://github.com/daviderez4/nodatachat-core) open repo. Challenges:* `security@nodatacapsule.com`.
