live · mainnetoc · docs
specs · api · guides
docs / the canonical message

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

RuleWhy
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.

ProtocolHeader literal
OC Attest (default)orangecheck
OC Attest (challenge / auth flow)orangecheck-auth
OC Lock device bindingoc-lock:device-bind:v2
OC Stamp envelopeoc-stamp:v1
OC Agent delegationoc-agent:delegation:v1
OC Agent actionoc-agent:action:v1
OC Agent revocationoc-agent:revocation:v1
OC Pledgeoc-pledge/v1
OC Pledge outcomeoc-pledge-outcome/v1
OC Pledge abandonmentoc-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 kind field (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 separator oc-vote/v0/commit used 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 d tag, 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.

ProtocolTypeScript corePython core (where shipped)
OC Attest@orangecheck/sdk (umbrella) + @orangecheck/attest-coreorangecheck (PyPI)
OC Lock@orangecheck/lock-core
OC Stamp@orangecheck/stamp-coreorangecheck-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