Signatures
The optional record-level `sigs` array — a detached COSE_Sign1 over the whole record body, its domain-separated signed payload, the two signer-key paths, and strict Ed25519 verification.
A Label 309 record MAY carry one or more authorship signatures in an optional
top-level sigs array. Each entry is a detached
COSE_Sign1 (RFC 9052) over the record
body, attesting that some key vouches for the record. Authorship is always
optional — the standard never requires a signature, and a record carrying no
sigs field is a complete, fully verifiable proof of existence.
A signature is additive: it answers "and this key vouches for it" on top of the timestamp claim, never instead of it. The content hash is the primary claim; a signature is metadata about who stands behind that claim. Critically, a signature the verifier cannot check — an unsupported algorithm, an unresolvable key — never invalidates the content or timestamp claim. Signatures fail softly; existence does not.
This page defines what a signature covers, the exact bytes that are signed, the
two ways a signer's public key is carried, and the strict verification a public
verifier performs. The Ed25519 key itself is defined on Keys; the
on-wire sigs field and its chunk-array transport are defined on
The record.
What a signature covers
A single sigs[i] entry attests to the entire record body, uniformly. There
is no per-item, per-URI, or per-field signature granularity: one signature
commits to every item, every storage URI, every encryption envelope, the
supersedes pointer if present, and every extension key the record carries. A
relay cannot add, drop, or rewrite any of those after the fact without breaking
the signature.
The signed body is the record map with the sigs field removed —
remove_keys(record_map, ["sigs"]), written here as record_body. The sigs
array is excluded from what each entry signs because a signature cannot cover
itself, and because each signer commits only to the claim, not to the roster of
co-signers. Concretely, every entry signs {v, items?, merkle?, supersedes?, crit?, <extensions?>} — the same record_body bytes for every entry — but no
entry signs the other entries in sigs. A signer therefore attests that the
body they signed is the body every other entry is bound to; no signer attests to
which other signers co-signed.
Signature scope is the record body, not the transaction
A verified signature proves that a key produced a signature over the record body. It does not prove that the same key submitted the carrying transaction, paid its fee, or chose its block time. An identical record body MAY be republished by any party in a later transaction — that is intentional record portability. Render a verified signature as "signed by <key>", never as "<key> submitted this" or "published by <key> at <time>".
The signed payload
Each entry carries a detached COSE_Sign1, so the COSE payload field is empty and the bytes that are actually signed are reconstructed by the verifier from the on-chain record. The signer computes:
record_body = remove_keys(record_map, ["sigs"])
record_body_bytes = canonical_cbor(record_body)
SIG_DOMAIN_RECORD = utf8("cardano-poe-record-sig-v1") ; 25 bytes
to_sign = SIG_DOMAIN_RECORD || record_body_bytes ; concatenation
Sig_structure = [ "Signature1", protected, h'', to_sign ]
signature = Sign(canonical_cbor(Sig_structure), signer_key)record_body is serialised as canonical CBOR per
RFC 8949 §4.2.1 — the same
deterministic encoding the whole record uses. Determinism is what makes a
signature interoperable: two implementations that encode the same logical body
produce byte-identical record_body_bytes, so a signature produced by one
verifies under another.
The domain-separation prefix
to_sign is the 25-byte UTF-8 string cardano-poe-record-sig-v1 prepended to
record_body_bytes. The prefix binds the signature to its Label 309 role and
prevents cross-protocol replay. A future Cardano metadata schema that happened to
share the body's CBOR shape (same keys, same types) could not reuse a Label 309
signature against itself: its to_sign would carry a different prefix, or none,
so the signed-byte sequence would differ and the signature would fail.
Implementations MUST embed this literal byte sequence as the leading bytes of
to_sign exactly; signing only the bare canonical CBOR, with no prefix, is
non-conformant.
Why external_aad is empty
Label 309 places the domain separator inside to_sign, not in COSE
external_aad. The external_aad slot (Sig_structure[2]) is always the
empty byte string h''. This is a deliberate departure from the usual COSE
pattern of putting a domain string into external_aad, and the reason is wallet
interoperability:
CIP-30
signData — the standard wallet-signing path on Cardano — stipulates that no
external_aad is used and gives a dApp no way to supply one. A non-empty
external_aad would make every wallet-produced signature fail. Embedding the
prefix in the payload preserves the identical anti-replay property while keeping
wallet-produced and verifier-recomputed bytes byte-for-byte equal.
The Sig_structure
Sig_structure is the 4-element COSE_Sign1 signing array of
RFC 9052 §4.4:
| Slot | Value | Notes |
|---|---|---|
[0] | "Signature1" | Fixed COSE context identifier, emitted as the full CBOR text string (11 bytes), never the bare UTF-8. |
[1] | protected | The signer's bstr-wrapped, canonical-CBOR protected-header bytes, used verbatim — never re-canonicalised by the verifier. |
[2] | external_aad | Always h'' (zero-length bstr). |
[3] | to_sign | The 25-byte prefix concatenated with record_body_bytes. |
The published COSE_Sign1 carries its payload field (COSE_Sign1[2]) as CBOR
null (0xF6) — the detached form. An attached payload, including a
zero-length byte string, is rejected. Detaching the payload is what pins the
signed bytes to the record body the verifier independently recomputes; an
attached form would let a producer sign borrowed bytes that bear no relation to
the on-chain claims.
Hardware-wallet hashed mode
CIP-30 / CIP-8 define
an optional unprotected-header "hashed": true flag a constrained hardware co-signer may set.
When present and true, Sig_structure[3] is the 28-byte Blake2b-224(to_sign) digest rather than
to_sign itself; the other three slots are unchanged. A verifier MUST inspect the unprotected
header and perform this substitution before strict Ed25519 verification. Software and SDK
producers SHOULD NOT set it — it saves no on-wire bytes and complicates verifier code paths.
Signing algorithm
The only signature algorithm in v1 is EdDSA over Ed25519
(RFC 8032), identified by COSE
alg = -8 (RFC 9053 §2.2),
which lives in the COSE_Sign1 protected header. A v1 verifier's mandatory
baseline is {-8}; it MAY additionally accept -19 (Ed25519,
fully-specified) and verify both codepoints under the same Ed25519 primitive.
The registry is extensible — future revisions add post-quantum signatures
additively, never as a breaking change.
Signer-key resolution
A public verifier must resolve the signer's public key without contacting any service, so every signature carries its key, or an unambiguous in-signature reference to it, on-chain. There are exactly two carry-forms in v1, and they are mutually exclusive within a single entry — an entry that uses both is a structural error.
Path 1 — identity signing (in-signature kid)
The 32-byte raw Ed25519 public key is placed at COSE header label 4 (kid,
RFC 9052 §3.1) inside the
protected header of the COSE_Sign1. The entry carries no cose_key field. By
the Label 309 convention, a protected-header kid of exactly 32 bytes is the
public key — not an opaque pointer to one looked up out-of-band. The 32-byte
length is an unambiguous discriminator: Ed25519 public keys are always 32 bytes.
Placing the key in the protected (not the unprotected) header binds it to the
signature; an adversary who rewrote it would break verification.
This convention is a deliberate, documented deviation from the opaque-identifier
reading of kid in RFC 9052; it is what makes the identity path
service-independent, with no key directory required. The key model is defined on
Keys.
Path 2 — wallet signing (inline cose_key)
A CIP-30 signData signature returns the signer's public key as a separate
cbor<COSE_Key> blob, not inside the COSE_Sign1. A producer chaining such a
signature into a record MUST place that COSE_Key (chunked) into the same
sigs[i] entry under the key cose_key. The verifier decodes it as a COSE_Key
and reads the Ed25519 public key from label -2. The COSE_Key MUST describe
only the public half — kty = OKP (1), crv = Ed25519 (6), the 32-byte x at
label -2 — and MUST NOT carry private-key material (label -4 and the
like); publishing a private scalar on a permanent ledger is an irreversible key
leak.
Mutual exclusion
The two paths are exclusive at the wire level. An entry carries either a
32-byte protected-header kid and no cose_key (path 1), or a
cose_key field and no 32-byte protected-header kid (path 2) — never both.
An entry that carries both is rejected; a verifier never has to disambiguate at
verification time. Resolution is therefore a wire-level discrimination, not a
ranked precedence:
| Path | Condition | Signer key |
|---|---|---|
| 1 | 32-byte protected kid, no cose_key | The 32-byte kid value, used directly. |
| 2 | cose_key present, no 32-byte kid | The Ed25519 key at COSE_Key label -2. |
A kid carried only in the unprotected header is not a sanctioned
resolution path: it sits outside the signed envelope, so a relay could rewrite it
without breaking the signature. A verifier MUST ignore unprotected-header
kid values for resolution. If no permitted path yields a 32-byte Ed25519 key,
the entry is reported unresolved and contributes no authorship claim.
Verification
A public verifier checks each sigs[i] independently, in this order:
- Decode. Concatenate the chunks of
sigs[i].cose_sign1and parse as a COSE_Sign1. The payload field MUST benull(detached); any non-null or non-empty payload is malformed. - Algorithm. Read the protected-header
alg. If it is outside the verifier's supported set, the entry is unsupported (see below) — not an error on the record. - Resolve the key. Apply the path-1 / path-2 discrimination above to obtain the 32-byte Ed25519 public key. If no path yields one, the entry is unresolved.
- Reconstruct and verify. Rebuild
to_signandSig_structure = ["Signature1", protected, h'', to_sign], canonical-CBOR-encode it, and verify the signature with strict Ed25519. (SubstituteBlake2b-224(to_sign)forto_signfirst if the unprotected header carries"hashed": true.) - Wallet binding (path 2 only). Recompute the stake address from the
resolved key and compare it byte-for-byte against the protected-header
address; mismatch fails the binding even though the Ed25519 signature itself verified. This path-2-only check is what lets a UI render a record as wallet-bound; path-1 entries skip it.
Strict Ed25519
Verification follows RFC 8032 §5.1.7 strict rules — there is exactly one acceptable answer for any given key, message, and signature:
- Non-canonical encodings of
Ror the signature scalarS(notably anyS ≥ ℓ, the group order) MUST be rejected. - Small-order / small-subgroup / torsion-component public keys and
Rvalues MUST be rejected. - The cofactored verification equation (the ZIP-215 / batch-friendly form) MUST NOT be substituted for the strict equation.
Strictness is what makes the verdict reproducible across implementations: a cofactored verifier would accept signatures a strict one rejects, so two conformant verifiers would disagree. Implementations must select a library — or a library mode — that performs strict, non-cofactored verification.
Verdict semantics
Signatures are additive, so an unverifiable signature is reported on the entry,
not promoted into a record-level failure. Each sigs[i] resolves to one of these
typed per-entry outcomes; the full error catalogue and the record-level verdict
rules live on Verification:
| Outcome | Meaning |
|---|---|
| verified | Strict Ed25519 (and, for path 2, the address binding) passed. |
| signature unsupported | The protected-header alg is outside the verifier's set. Info, never an error. |
| signer key unresolved | No permitted path yields a 32-byte Ed25519 public key. |
| signature invalid | Strict Ed25519 returned false over the reconstructed Sig_structure. |
| wallet address mismatch | Path 2: the signature verified, but the recomputed stake address ≠ the claimed one. |
An unsupported signature never invalidates the proof
An unrecognised or unsupported signature algorithm yields a typed signature-unsupported
outcome at info severity. The content and timestamp claim — the on-chain hashes commitment — is
structurally valid regardless of which signature algorithms a verifier implements. A record
carrying only future-algorithm signatures still surfaces as a valid proof of existence, with each
such entry tagged unsupported. Signatures are additive; existence does not depend on them.
Related pages
- Keys — the Ed25519 signing key, its derivation, and the
32-byte public key carried in path-1
kid. - The record — the top-level
sigsfield, the closedsig-entrymap, and the ≤ 64-byte chunk-array transport. - Verification — the per-entry outcome codes, the record-level verdict rules, and the full validation pipeline.
Keys
The Label 309 key model — one 32-byte seed, three algorithm keypairs derived from it by domain-separated HKDF-SHA-256, and how recipient public keys are encoded and exchanged.
Sealed PoE
The Label 309 encryption envelope — how a sender seals content to one or more recipient keys while the chain carries only the plaintext hash and the wrapped key slots, never the plaintext and never the recipients.