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 removedremove_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:

SlotValueNotes
[0]"Signature1"Fixed COSE context identifier, emitted as the full CBOR text string (11 bytes), never the bare UTF-8.
[1]protectedThe signer's bstr-wrapped, canonical-CBOR protected-header bytes, used verbatim — never re-canonicalised by the verifier.
[2]external_aadAlways h'' (zero-length bstr).
[3]to_signThe 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:

PathConditionSigner key
132-byte protected kid, no cose_keyThe 32-byte kid value, used directly.
2cose_key present, no 32-byte kidThe 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:

  1. Decode. Concatenate the chunks of sigs[i].cose_sign1 and parse as a COSE_Sign1. The payload field MUST be null (detached); any non-null or non-empty payload is malformed.
  2. 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.
  3. 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.
  4. Reconstruct and verify. Rebuild to_sign and Sig_structure = ["Signature1", protected, h'', to_sign], canonical-CBOR-encode it, and verify the signature with strict Ed25519. (Substitute Blake2b-224(to_sign) for to_sign first if the unprotected header carries "hashed": true.)
  5. 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 R or the signature scalar S (notably any S ≥ ℓ, the group order) MUST be rejected.
  • Small-order / small-subgroup / torsion-component public keys and R values 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:

OutcomeMeaning
verifiedStrict Ed25519 (and, for path 2, the address binding) passed.
signature unsupportedThe protected-header alg is outside the verifier's set. Info, never an error.
signer key unresolvedNo permitted path yields a 32-byte Ed25519 public key.
signature invalidStrict Ed25519 returned false over the reconstructed Sig_structure.
wallet address mismatchPath 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.

  • Keys — the Ed25519 signing key, its derivation, and the 32-byte public key carried in path-1 kid.
  • The record — the top-level sigs field, the closed sig-entry map, and the ≤ 64-byte chunk-array transport.
  • Verification — the per-entry outcome codes, the record-level verdict rules, and the full validation pipeline.