The canonical message
Every protocol in the OrangeCheck family signs a canonical message — a deterministic UTF-8 text blob that the wallet feeds to BIP-322. The same shape is reused across Attest, Lock, Stamp, Vote, Agent, and Pledge. By specifying the bytes exactly, two independent implementations produce byte-identical messages and therefore byte-identical attestation IDs.
Shape
<protocol-header>
<field-1>: <value>
<field-2>: <value>
…
<extension>: <value>
<ack>: <value>
Per-protocol specifics — header literal, required fields, allowed extensions —
live in the oc-*-protocol spec repos. The grammar is shared and covered
here.
Invariants
| Rule | Why |
|---|---|
| UTF-8 only. No BOM. | Deterministic encoding. |
LF line endings. One trailing \n. No CRLF. | Any variation changes the SHA-256 and therefore the attestation ID. |
Header is the first line literal, e.g. orangecheck for Attest, oc-lock for Lock, etc. | The header is how verifiers decide which parser to run. |
Fields are name: value with exactly one space after the colon. | No name:value, no tabs, no double spaces. |
| Extensions are lexicographically sorted by name. | Two implementations adding the same extensions in different orders must still produce the same bytes. |
Identifier lists are comma-separated with no space (nostr:npub1…,github:alice). | Identifiers MUST NOT contain a comma (enforced by schema). |
Timestamps are ISO 8601 with millisecond precision, ending in Z. | Example: 2026-04-24T06:47:29.977Z. |
| Nonces are 32 lowercase hex characters (16 random bytes). | Case-sensitive — ABCDEF is rejected. |
Any deviation — extra whitespace, wrong newline style, unsorted extensions,
capital hex in a nonce — makes verification fail with decode_error. This is
intentional. A forgiving parser would let two implementations produce
attestations with the same inputs but different IDs.
Example (OC Attest)
orangecheck
identities: github:alice,nostr:npub1alice...
address: bc1qalice...
purpose: forum-post
nonce: a3f5b8c2d1e4f6a7b8c9d0e1f2a3b4c5
issued_at: 2026-04-24T06:47:29.977Z
ack: I attest control of this address and bind it to my identities.
Seven lines. One trailing newline. That's the whole thing.
Per-protocol headers
Each header is a literal first-line domain separator. A signature produced under one header cannot be replayed against another protocol's verifier — the prefix is part of the bytes the wallet signed.
| Protocol | Header literal |
|---|---|
| OC Attest (default) | orangecheck |
| OC Attest (challenge / auth flow) | orangecheck-auth |
| OC Lock device binding | oc-lock:device-bind:v2 |
| OC Stamp envelope | oc-stamp:v1 |
| OC Agent delegation | oc-agent:delegation:v1 |
| OC Agent action | oc-agent:action:v1 |
| OC Agent revocation | oc-agent:revocation:v1 |
| OC Pledge | oc-pledge/v1 |
| OC Pledge outcome | oc-pledge-outcome/v1 |
| OC Pledge abandonment | oc-pledge-abandonment/v1 |
Two sibling protocols don't use the line format
- OC Lock envelope canonicalization — Lock envelopes carry binary AES-GCM ciphertext that doesn't fit cleanly into a line-format text blob, so the envelope is canonicalized as RFC-8785 JSON instead. The signed device binding (above) still uses the line format.
- OC Vote poll / ballot — Polls and ballots are canonical JSON objects
with a
kindfield (oc-vote/poll,oc-vote/ballot); BIP-322 signs the SHA-256 hex of the canonical bytes rather than the multi-line text. OC Vote also defines a small line-format domain separatoroc-vote/v0/commitused inside secret-mode ballot commitments.
The text-line canonical format described on this page applies to Attest, Lock device binding, Stamp, Agent (all three envelopes), and Pledge (all three envelopes). Vote and Lock-envelope use canonical JSON; consult those specs directly for their exact byte layout.
Versioning
The header literal is frozen at v0. Any change to the format — new required
field, different whitespace rule, anything — requires a new header (e.g.
orangecheck-v1) and breaks every existing signature.
This is why the v0 header doesn't include a version number: adding one later would have been a breaking change anyway.
Why strict canonicalization matters
The attestation ID is sha256(canonical_message_bytes). Two implementations
that agree on the inputs but disagree on the bytes produce different IDs. That
breaks:
- Nostr discovery — events are addressed by the
dtag, which contains the attestation ID. - Conformance testing — the whole point of the conformance vectors is to lock the format byte-by-byte.
- Trust — a gate that re-hashes the message and gets a different ID than the attestation claims has to reject it as tampered.
Implementations
Each protocol's reference core ships its own canonical-message builder. They all follow the same shape rules described above; the per-protocol code is a thin shim that prepends the header literal and validates the per-protocol field set.
| Protocol | TypeScript core | Python core (where shipped) |
|---|---|---|
| OC Attest | @orangecheck/sdk (umbrella) + @orangecheck/attest-core | orangecheck (PyPI) |
| OC Lock | @orangecheck/lock-core | — |
| OC Stamp | @orangecheck/stamp-core | orangecheck-stamp (PyPI) |
| OC Vote | @orangecheck/vote-core | — |
| OC Agent | @orangecheck/agent-core | — |
| OC Pledge | @orangecheck/pledge-core | — |
Every core vendors its protocol's test-vectors/*.json from the matching
oc-*-protocol repo and runs them on every CI push, so canonical-message drift
between spec and impl is caught before publish.
See also
- BIP-322 signing — what the wallet actually signs
- Conformance vectors — how we prove the two impls agree
- OC Attest: verification — what a verifier must check