# Predge signed records: canonicalization and verification

Version 1, 2026-10-08. Applies to every envelope with `"version": "predge-attest-v1"`. That includes `GET /v1/settlement-risk/{market}`, the attest routes, calls, track records and staked signals.

Source of truth in code: `canonicalJson()` and `buildSignedEnvelope()` in the Predge API (`src/lib/attest.ts`). Published at https://data.predge.io/canonicalization.md.

## 1. What is signed

A signed record carries an envelope (under `attestation` in API responses):

| Field | Meaning |
|---|---|
| `payload` | The signed facts, as a JSON object |
| `canonical` | The exact string that was signed: `payload` canonicalized by the rule below |
| `signature` | ed25519 signature over the UTF-8 bytes of `canonical`, 64 bytes as lowercase hex |
| `public_key` | Raw 32-byte ed25519 public key as lowercase hex |
| `version`, `issuer`, `algorithm`, `kind`, `data_basis`, `verify` | Annotations. **Not** covered by `signature` |
| `kid`, `content_hash`, `envelope_signature` | Present when the envelope itself is signed (section 5) |

Everything outside the envelope (market title, `context`, `risk_level` at the top level of a response, and so on) is display data and is not signed. Trust `payload` only after you have checked that it rebuilds `canonical`, or parse `canonical` yourself.

## 2. The canonicalization rule

`canonical = C(payload)`, where `C` is:

- **Object:** `{` + members joined by `,` + `}`. Each member is `C(key) + ":" + C(value)`. Members are sorted by key in ascending order of UTF-16 code units, which is plain byte order for the ASCII snake_case keys Predge uses. A key whose value is `undefined` is left out; that never happens on the wire.
- **Array:** `[` + elements in their original order, each as `C(element)`, joined by `,` + `]`.
- **String:** as ECMAScript `JSON.stringify` writes it.
  - `"` and `\` are escaped as `\"` and `\\`.
  - U+0008, U+0009, U+000A, U+000C and U+000D become `\b`, `\t`, `\n`, `\f` and `\r`.
  - Any other code point below U+0020 becomes `\u00xx`, with lowercase hex.
  - Everything else is written as is: non-ASCII letters, emoji, `/`, U+007F, U+2028 and U+2029. There is no `\uXXXX` escaping of non-ASCII.
- **Literals:** `true`, `false`, `null`.
- **Numbers:** Predge payloads do not contain JSON numbers. Counts, prices and amounts are decimal strings, for example `"dispute_count": "2"` and `"amount": "5000"` (atomic USDC units). This avoids float formatting differences between languages. If a number ever appears, it is written as ECMAScript `Number.prototype.toString` writes it.
- **No whitespace** anywhere outside strings. No BOM and no trailing newline.

The bytes that are signed and hashed are the UTF-8 encoding of `canonical`.

### Relation to RFC 8785 (JCS)

For payloads made only of objects, arrays, strings, booleans and null, with ASCII keys, this rule gives the same bytes as JCS. Every Predge payload is of that kind. JCS adds number formatting rules, which Predge does not need, and sorts keys by UTF-16 code units, which matches the rule above.

### Python equivalent

```python
json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
```

- `ensure_ascii=False` is required. With the default `ensure_ascii=True`, Python writes `é` as `é` and the bytes no longer match. Today's settlement-risk records are pure ASCII, so both settings happen to agree on them. Do not rely on that.
- Python escapes control characters exactly as `JSON.stringify` does, with lowercase hex. It sorts keys by code point, which equals UTF-16 order for ASCII keys.
- Lone UTF-16 surrogates never occur: Predge decodes all chain text as UTF-8 with replacement.
- The API's test suite runs both implementations on a production record and on edge-case strings (quotes, backslash, control characters, accented Latin, CJK, emoji, U+2028) and requires identical bytes.

## 3. Content hash

```
content_hash = lowercase hex of sha256(UTF-8 bytes of canonical)
```

This is the value used in `/r/{id}` evidence pages and in the `content_hash` / `prev_hash` chain of signed calls. Envelopes with a whole-envelope signature also carry it as `content_hash`.

## 4. Verifying

Check four things, in this order:

1. `C(payload) == canonical`. This proves the parsed object you read is the one that was signed.
2. If the record has `content_hash`, check that it equals `sha256(canonical)`.
3. ed25519 verification of `signature` over `canonical` with `public_key`. This is pure ed25519 (RFC 8032), not ed25519ph.
4. `public_key` is listed with `"active": true` in the key set (section 6). A valid signature under a key that is not listed is not a Predge signature.

Ready-made scripts: [verify-record.mjs](https://data.predge.io/verify-record.mjs) and [verify_record.py](https://data.predge.io/verify_record.py).

```
node verify-record.mjs record.json                              # Node 18+, no packages
python3 verify_record.py record.json                            # Python 3.8+, needs cryptography or PyNaCl
python3 verify_record.py record.json --keys predge-keys.json    # use a saved key set
```

Each script accepts a full API response, a bare envelope or an evidence pack. It prints PASS or FAIL per check and exits 0 only if every check passes.

### Python, minimal

```python
import hashlib, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

env = json.load(open("record.json", encoding="utf-8"))["attestation"]
assert json.dumps(env["payload"], sort_keys=True, separators=(",", ":"), ensure_ascii=False) == env["canonical"]
print("content_hash", hashlib.sha256(env["canonical"].encode("utf-8")).hexdigest())
Ed25519PublicKey.from_public_bytes(bytes.fromhex(env["public_key"])).verify(
    bytes.fromhex(env["signature"]), env["canonical"].encode("utf-8"))   # raises if invalid
```

### JavaScript (Node 18+), minimal

```js
import { readFileSync } from "node:fs";
import { createHash, createPublicKey, verify } from "node:crypto";

const C = (v) => v === null || typeof v !== "object" ? JSON.stringify(v)
  : Array.isArray(v) ? `[${v.map(C).join(",")}]`
  : `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${C(v[k])}`).join(",")}}`;

const env = JSON.parse(readFileSync("record.json", "utf8")).attestation;
console.log(C(env.payload) === env.canonical);
console.log(createHash("sha256").update(env.canonical, "utf8").digest("hex"));
const key = createPublicKey({ format: "der", type: "spki", key: Buffer.concat([
  Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(env.public_key, "hex")]) });
console.log(verify(null, Buffer.from(env.canonical, "utf8"), key, Buffer.from(env.signature, "hex")));
```

In a browser, WebCrypto `crypto.subtle.verify("Ed25519", …)` works on the same bytes. data.predge.io/verify does this.

## 5. Whole-envelope signature

Some routes also sign the envelope, so that annotations such as `kind` and `data_basis` cannot be swapped without detection. `/v1/settlement-risk` does this from the release that ships this document. An envelope signed this way carries three extra fields:

- `kid`: the first 16 hex characters of `public_key`;
- `content_hash`: as in section 3;
- `envelope_signature`: ed25519, with the same key, over `C(envelope without the envelope_signature field)`.

To verify, remove `envelope_signature`, canonicalize the rest with the rule above, and verify the signature over its UTF-8 bytes. Both scripts do this when the field is present. Routes that do not opt in keep their earlier wire shape byte for byte.

## 6. Keys and rotation, as they are today

**Key set:** https://api.predge.io/.well-known/predge-keys.json. It has an array of keys, the verify recipe and the rotation notice.

| kid | Role | Signs | Where the private key lives |
|---|---|---|---|
| `13fa3d18a369e6c7` | `attestation` | Every API record: settlement risk, attest, calls, track records, stakes | API server environment only |
| `a122cc095c0f7fe5` | `cachet-oracle` | Market resolutions recorded on Circle Arc | An operator machine; the API publishes only the public key |

`kid` is always the first 16 hex characters of the public key. Full attestation key: `13fa3d18a369e6c71bf941563ba47822b30182273d5106a0e8fb61c5016352d9`. The same key has been published at data.predge.io/.well-known/predge-attest.json since July 2026.

**Policy.** This is the `rotation` field of the key set, in substance:

- A replacement key is published in the key set with `active: false` at least 30 days before it signs anything.
- At cutover it becomes `active: true`. The key it replaces stays listed with `active: false`, so older records still verify.
- A key revoked for compromise is removed immediately, without the 30-day notice.

**Current state:**

- No rotation has happened. `13fa3d18a369e6c7` is the only attestation key that has ever signed production records.
- The key set is built from the server's current signing key plus the configured oracle public key. It has no slot yet for an announced or retired key. Listing either one needs a small code change, and that change will ship before any rotation starts, so the 30-day notice can be met.
- Keys that are **not** Predge keys:
  - `faf500dd…` is the throwaway prototype key used on the first sample pack;
  - `cc2a41ac…` is a pre-release key used on internal drafts.

  Neither one has ever been in the key set. A signature under either proves only that the file has not changed since it was signed.

## 7. Changes to this document

The rule in section 2 is frozen for `predge-attest-v1`. Any change to the bytes would come with a new `version` value, and both versions would stay verifiable.
