13 KiB
Trusty payload format -- v1 + v2
Canonical byte-level specification of what goes into raw_payload, what
row_hash commits to, and how v2 signatures are produced and verified.
This document is consensus-critical. v1 byte-shape is locked by the
invariant tests in trusty-core (see tag s5_invariant_v1_byte_shape_preserved
and the v1_* tests in payload.rs / chain.rs). v2 is purely additive
and must preserve v1 bytes exactly when payload_version=1. The original
Python port-source is archival only and no longer gates changes here.
Source of truth in-tree:
crates/trusty-core/src/payload.rs-- canonical serializer, constants.crates/trusty-core/src/chain.rs--compute_record,compute_record_signed,verify_links,verify_row_signatures.crates/trusty-core/src/hash.rs--row_hash,anchor_hash.crates/trusty-core/src/crypto.rs--Signer,Verifier, Ed25519 impls.
1. Canonical form
Every raw_payload is a JSON object. Its canonical byte form is:
- Keys sorted alphabetically, recursively (every nested object gets the
same treatment). Implemented via
BTreeMapat the top level and a recursivecanonicalizeon all nestedserde_json::Value::Objectnodes. - No whitespace --
serde_json::to_string(notto_string_pretty). - UTF-8. No BOM.
- Array order preserved. Only object keys are sorted; arrays keep caller
order (this matters for
signatures, which is an array -- but each entry inside is itself an object and gets its keys sorted).
1.1 Field list (alphabetical -- the on-wire order)
| Field | Type | Notes |
|---|---|---|
chain_id |
string | chain_<entity_id>_<6-digit-generation> (e.g. chain_acme_000001). |
data |
any JSON | Caller-provided. Canonicalized recursively. Capped at 65,536 bytes after canonicalization. |
detected_at |
string | ISO-8601 UTC. Time trusty observed the event. |
entity_id |
string | Opaque caller identifier. |
extraction_method |
string | Caller tag for the provenance source (e.g. trusty, scraper-v3). |
hash_alg |
string | Always "siphash24+sha256" today. Verifiers read this before recomputing row_hash. |
payload_version |
number | 1 (v1) or 2 (v2). |
prev_hash |
string | Previous row's row_hash (16 hex chars), or the literal "genesis" on a segment's first row. |
received_at |
string | ISO-8601 UTC with sub-second precision. Distinct from detected_at; never collapse. |
sequence |
number | Per-chain monotonically increasing i64. Genesis = 0. |
signatures |
array | v1: always []. v2: one object per signer (see Section 3). |
source_url |
string | Where the data came from. |
1.2 data size cap
MAX_DATA_BYTES = 65_536 enforced after canonicalization. Exceeding it
raises TrustyError::DataTooLarge. Put blobs in object storage and reference
them by {uri, sha256} inside data -- do not inline them.
2. Dual hash
Each row carries two hashes. Both are computed in trusty-core/src/hash.rs
and are byte-identical to the Python reference.
2.1 row_hash -- SipHash-2-4
- Keyed with the 16-byte
SecretKey(split intok0/k1as two little-endianu64s,bytes[0..8]andbytes[8..16]). - Input: the exact canonical payload bytes (
raw_payloadabove). - Output: 64-bit digest rendered as lowercase hex, 16 chars, wire
representation derived from
format!("{:016x}", digest). This is a big-endian-style hex rendering of the integer value. BE hex is canonical; any legacy LE rendering (e.g. earlier threadkeeper code) is non-canonical and must be adapter-converted when crossing the boundary. See tags2_decision_be_encoding_canonical.
2.2 anchor_hash -- SHA-256
Input byte layout:
row_hash_bytes || 0x00 || entity_id_bytes || 0x00 || chain_id_bytes
All three text fields are UTF-8. Output is lowercase hex, 64 chars. Null bytes are domain separators between fields -- do not drop them.
The dual-hash shape (keyed SipHash + keyless SHA-256 with null-byte domain
separation) is locked in trusty-core/src/hash.rs and covered by the
anchor_hash_* tests there. It is the only hash layout trusty recognises.
2.3 Why two hashes
SipHash-2-4 is a keyed fingerprint: cheap, fast, tamper-evident against adversaries who do not hold the key. It is the chain-integrity primitive.
SHA-256 is the cryptographic anchor: public, collision-resistant, suitable for external receipts and cross-system reference. The anchor is what you publish or notarize; the row hash is what you use to link the chain.
Dual hash is non-negotiable -- do not collapse to one. See
CLAUDE.md#design-rules.
3. v2 signatures
v2 adds authenticated signatures over the payload. v1 is unchanged.
3.1 Shape of a signature entry
{"alg":"ed25519","pk":"<hex>","sig":"<hex>"}
- Keys alphabetical (enforced by recursive canonicalization).
alg: today always"ed25519". Future algorithms add new literal values, not OIDs.pk: hex-encoded public key bytes (32 bytes for Ed25519 -> 64 hex chars).sig: hex-encoded signature bytes (64 bytes for Ed25519 -> 128 hex chars).
signatures is an array so multi-signer is a pure extension -- just append
additional entries. Verifier iterates and validates each independently.
3.2 The signing-view algorithm
The core problem: if row_hash commits to the payload, and the payload
contains the signature, the signature cannot sign the final bytes (it would
have to contain itself).
Solution -- sign a canonical view with signatures forced empty, then
splice the signature in, re-canonicalize, and hash the spliced bytes. Both
sides of the envelope commit to each other:
Sign (emission):
- Start from caller's
PayloadFields. - Force
payload_version = 2,signatures = []. - Build the signing view: canonical bytes with
signatures=[].payload::signing_view_bytes(&fields). - Signer produces
sig = sign(signing_view_bytes). - Splice
{alg, pk, sig}(all hex-encoded) intosignatures. - Re-canonicalize -- these are the final stored bytes (
raw_payload). row_hash = SipHash-2-4(key, raw_payload).anchor_hash = SHA-256(row_hash || 0x00 || entity_id || 0x00 || chain_id).
The row_hash therefore commits to the signature; the signature commits to everything except itself (via the signing view).
Verify (consumption):
- Parse
raw_payloadback intoPayloadFields(chain::parse_raw_payload). - If
payload_version < 2orsignaturesis empty -> skip signature check. v1 rows and unsigned v2 rows pass the signature stage by design. - Rebuild the signing view by replacing
signatureswith[]and re-canonicalizing. - For each signature entry:
- Dispatch on
alg. Unknown ->UnknownSignatureAlg { alg }. - Hex-decode
pkandsig. Decode failure ->SignatureMalformed. Ed25519Verifier::verify(&pk, signing_view_bytes, &sig). Failure ->SignatureInvalid.
- Dispatch on
row_hashis checked independently against the full stored bytes. Tampering with just the sig bytes firesRowHashMismatch; recomputing row_hash to hide the sig-tamper still firesSignatureInvalid. Attackers lose both ways.
3.3 Worked example (conceptual)
Starting fields (abbreviated):
{
"chain_id": "chain_acme_000001",
"data": {"temp_c": 21},
"entity_id": "acme",
"payload_version": 2,
"prev_hash": "genesis",
"sequence": 0,
"signatures": [],
...
}
Signing view = canonical bytes of that object exactly as shown (with all other canonical fields populated).
Signer produces sig over those bytes. The final stored bytes:
{
"chain_id": "chain_acme_000001",
"data": {"temp_c": 21},
"entity_id": "acme",
"payload_version": 2,
"prev_hash": "genesis",
"sequence": 0,
"signatures": [{"alg":"ed25519","pk":"ab...cd","sig":"12...ef"}],
...
}
row_hash = siphash24(key, <final bytes>). This is what lands in the DB.
3.4 Nested object canonicalization (why it matters for v2)
Signature entries are JSON objects. They MUST be canonicalized (keys sorted)
before serialization, otherwise serde_json::preserve_order leaks caller
insertion order into the canonical bytes and different emitters produce
different row_hash for the same logical signature. build_payload
explicitly canonicalizes the signatures array via the recursive
canonicalize helper. See tag
s5_learn_preserve_order_requires_canonicalize_nested.
4. VerifyError taxonomy
verify_links returns VerifyReport { ok, row_count, errors }. Errors
carry {sequence, chain_id, kind}; the kinds relevant to payload format:
| Kind | Trigger |
|---|---|
PrevHashMismatch |
link.prev_hash != previous row's row_hash (or genesis on first row). |
MissingRawPayload |
Non-BREAK row with no raw_payload. |
InvalidRawPayload |
raw_payload does not parse as JSON or is missing required fields. |
MissingRowHash |
Non-BREAK row with no row_hash. |
RowHashMismatch |
Recomputed SipHash != stored row_hash. |
AnchorHashMismatch |
Recomputed SHA-256 != stored anchor_hash (when stored). |
SignatureInvalid |
v2: Ed25519 verify failed against signing view. |
SignatureMalformed |
v2: signature entry not a JSON object, pk/sig hex decode failed. |
UnknownSignatureAlg |
v2: alg field not recognized (only ed25519 today). |
BREAK markers carry no hashes and reset prev to genesis for the next
segment. They are not counted in row_count.
5. API surface
5.1 trusty-core (pure library)
use trusty_core::{
compute_record, compute_record_signed,
Signer, Ed25519Signer,
PayloadFields, SecretKey,
verify_links,
};
// v1 path -- unsigned.
let rec = compute_record(&fields, &key)?;
// rec.raw_payload has "payload_version":1, "signatures":[]
// v2 path -- signed with any Signer impl.
let signer = Ed25519Signer::generate();
let rec = compute_record_signed(&fields, &key, &signer)?;
// rec.raw_payload has "payload_version":2 and one signatures entry.
// Caller's fields.payload_version and fields.signatures are overridden.
// Verify (version-agnostic).
let report = verify_links(&entity_id, &links, &key);
assert!(report.ok);
The Signer trait is byte-oriented:
pub trait Signer {
fn sign(&self, msg: &[u8]) -> Vec<u8>;
fn public_key_bytes(&self) -> Vec<u8>;
}
HSM or remote-signer integrations implement this trait directly; no ed25519-dalek dependency leaks into chain code.
5.2 trusty-sqlite (storage adapter)
let store = Trusty::open(path, key)?;
// v1 append
let receipt = store.append(entity, data, source_url)?;
// v2 append -- threads Option<&dyn Signer> through the append pipeline
let receipt = store.append_signed(entity, data, source_url, &signer)?;
// Custom extraction_method
store.append_with_method(entity, data, source_url, method)?;
store.append_with_method_signed(entity, data, source_url, method, &signer)?;
Mixed v1/v2 chains verify fine; dispatch is per-row on stored
payload_version. You can flip signing on/off per-row at will.
6. Locked invariants
- v1 byte-shape is frozen. Same field set, same canonical ordering, same
hash_algstring,signatures:[],payload_version:1. Tag:s1_invariant_payload_v1_canonical. - v1 is preserved under v2. v2 was shipped additive-only in S5 --
calling
compute_recordon a v1PayloadFieldsemits exactly the same bytes it did before v2 existed. Tag:s5_invariant_v1_byte_shape_preserved. - row_hash BE-hex encoding is canonical. Decision
s2_decision_be_encoding_canonical. - Dual hash inputs are locked. SipHash-2-4 keyed + SHA-256 unkeyed with
null-byte domain separation. Tag:
s1_invariant_payload_v1_canonical. - Signature algorithm string is a literal. No OID dance, no algorithm negotiation. Adding an algorithm = adding a new literal value in verify dispatch.
- row_hash commits to the signature bytes (v2). Anything that would make
the signature not affect
row_hashis a regression.
New algorithms, new fields, new versions must be purely additive and must
leave v1 bytes byte-identical. Run cargo test -p trusty-core and inspect
the v1_* tests before and after any payload-layer change.