- BLAKE3 keyed proof-of-work on object storage and auth challenges, with frozen vectors cross-checked against an independent Python reference implementing the single-block hash it needs. - Sparse Merkle trie over object IDs: order-independent roots, inclusion and absence proofs (internal/smt). - Signed checkpoint chain per relay: transport key amendment to INV-1, /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe epoch continuity (internal/checkpoint). - Head gossip with TOFU pinning and equivocation detection; light node (cmd/lightnode) that stores no history: quorum of pinned relays, every served object proven against the agreed root, LRU disk cache. - WebSocket streaming on relay and light node (coder/websocket): scoped channels mirroring REST, raw envelopes verified client-side; light node marks streamed objects unproven until checkpoint coverage. - Protocol v1 additions: DelegationClaim tag 0x07 with deterministic chain resolution in verify.Graph, KeyRotationRequest/Confirm tags 0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge; spec sections, frozen vectors appended byte-identically, Python reference extended. - Optional permissioned BFT finality over gossip (internal/bft): prevote/precommit with quorum certificates verifiable offline. - Quick wins: Policy.TrustedIssuers, per-type stored metrics, batch fetch, lexicographic lists with stable cursor pagination. - Security review of the network layer (docs/SECURITY-REVIEW.md) with findings F-01..F-09; hub send/close race and unstable pagination fixed under review. 12 packages green, vet/gofmt clean, protocol fuzzing stable.
35 KiB
TCE — Trust Canonical Encoding, version 1
This document specifies the byte-exact representation of every signed object in the trust protocol. It is normative. An implementation in any language that follows it will produce identical bytes, identical object IDs and identical signatures.
Frozen test vectors accompany this document at
testdata/vectors/tce_vectors.json, and an executable reference
implementation at tools/reference/tce_reference.py.
Companion documents: INVARIANTS.md, ADDRESS.md.
1. Why a binary encoding rather than JSON
Signatures are computed over TCE bytes and never over JSON.
JSON has no canonical form. Key order, whitespace, escaping (/ vs \/,
é vs \u00e9), and number spelling (1, 1.0, 1e0) all vary between
libraries while denoting the same document. Signing a JSON document therefore
means signing whichever spelling the local library happened to emit, and a
verifier that re-serialises before checking will sometimes disagree. That is
a correctness bug in the best case and a signature-stripping vulnerability in
the worst.
TCE has exactly one encoding per value. It is length-prefixed rather than delimited, so no escaping exists and no parser lookahead is required. Every field is fixed in position and every length is explicit, so two different byte strings can never decode to the same object.
JSON remains the transport syntax. The wire form of an object carries the TCE bytes (base64) plus a decoded JSON view for humans and for debugging. A verifier must verify the TCE bytes it received and then, if it needs fields, decode them from those same bytes. It must never re-encode the JSON view and verify that. See §12.4.
2. Framing
Every TCE object is:
MAGIC || object_tag || version || field_1 || field_2 || ... || field_n
| Element | Size | Value |
|---|---|---|
MAGIC |
21 bytes | ASCII trust.n1ko.dev/tce/1 followed by 0x00 |
object_tag |
1 byte | see §3 |
version |
uvarint | 1 for this specification |
MAGIC in hex:
74 72 75 73 74 2e 6e 31 6b 6f 2e 64 65 76 2f 74 63 65 2f 31 00
The magic string is domain separation at the outermost level. It ensures that
a TCE object can never be mistaken for, or reinterpreted as, a signed message
from a different protocol that happens to share a key. The trailing 0x00
terminates the ASCII portion so that a longer magic in a future version cannot
be a prefix of this one.
The 1 in the magic is the framing version and the version uvarint is
the object version. They are separate: the framing version changes only if
the encoding rules themselves change, while the object version changes when an
object's field list changes. In version 1 they are both 1.
There is no length field for the object as a whole. The object ends when its last field ends, and any trailing byte is an error (§12.3).
3. Object tags (domain separation)
| Tag | Object | Signed by | § |
|---|---|---|---|
0x00 |
permanently reserved, never valid | — | |
0x01 |
IdentityRegistration |
the key itself | §8.1 |
0x02 |
Claim |
issuer | §8.2 |
0x03 |
Revocation |
issuer of the target claim | §8.3 |
0x04 |
ApprovalRequest |
sender | §8.4 |
0x05 |
ApprovalResponse |
responder | §8.5 |
0x06 |
AuthAssertion |
the asserting identity | §8.6 |
0x07 |
DelegationClaim |
the granter | §8.7 |
0x08 |
KeyRotationRequest |
the successor | §8.8 |
0x09 |
KeyRotationConfirm |
the predecessor | §8.9 |
0x0a–0x7f |
reserved for future object types | — | |
0x80–0xff |
permanently reserved | — |
The tag appears immediately after the magic, before any field. Because it is inside the signed bytes, a signature over one object type can never be replayed as another type: changing the tag changes the message, so the signature fails.
0x00 is reserved so that an all-zero buffer is never a valid object.
4. Primitive encodings
4.1 uvarint
Unsigned LEB128, little-endian groups of 7 bits, high bit set on every byte except the last.
encode(n): while n >= 0x80: emit((n & 0x7f) | 0x80); n >>= 7
emit(n)
Rules:
- Canonical (shortest) form is mandatory. A multi-byte encoding whose
final byte is
0x00is a longer spelling of a shorter value and must be rejected. For example1is01; the sequence81 00also decodes to 1 under a naive decoder and is invalid. - Maximum 10 bytes, maximum value 2^64 − 1. Longer input is rejected before the value is accumulated, so a hostile length prefix cannot cause unbounded work.
- A decoder must reject a uvarint that is truncated by the end of input.
4.2 Byte strings
enc_bytes(b) = uvarint(len(b)) || b
The length is in bytes, always. A decoder must reject a length that exceeds the remaining input, and must apply the field's maximum length (§6) before allocating.
4.3 Strings
enc_string(s) = uvarint(len(utf8(s))) || utf8(s)
Validation, applied on both encode and decode:
- Must be well-formed UTF-8. Overlong encodings, truncated sequences and
encoded surrogates (
U+D800–U+DFFF) are rejected. - No C0 controls (
U+0000–U+001F), noU+007F, no C1 controls (U+0080–U+009F). - No normalisation is performed. The bytes are signed exactly as supplied. Two strings that are visually identical but differently normalised are different strings; the protocol does not attempt to unify them, because silently rewriting a user's data before signing it would mean the user signs something other than what they reviewed.
- No BOM handling.
U+FEFFis an ordinary character. - The empty string is valid and encodes as the single byte
0x00.
4.4 Identity fields
enc_identity(pubkey) = uvarint(address_version) || uvarint(32) || pubkey
with address_version = 0 in version 1, giving the fixed 34-byte sequence
00 20 <32 bytes>.
The raw public key is encoded, not the bech32m address text. The address is a presentation format for humans; the key is the identity. Signing the key means a future change to address rendering cannot invalidate existing signatures, and it removes bech32 parsing from the verification path.
Every public key must pass the validation in ADDRESS.md: 32 bytes, canonical encoding, a curve point, not of small order. A decoder must reject an object whose identity field fails that check.
4.5 Fixed-size binary fields
Nonces (16 bytes), hashes (32 bytes) and challenges (32 bytes) are encoded
with enc_bytes, so the length prefix is present even though the length is
fixed. The redundancy is intentional: it keeps every field self-delimiting, so
a decoder never depends on out-of-band knowledge of a field's width. A decoder
must reject a length that is not exactly the value required for that field.
4.6 Timestamps
Unsigned seconds since the Unix epoch, UTC, as a uvarint.
- Valid range: 1000000000 (2001-09-09T01:46:40Z) to 4102444800 (2100-01-01T00:00:00Z), inclusive.
- The lower bound rejects a zero or obviously uninitialised value.
- The upper bound bounds all arithmetic and keeps the field five bytes.
- No sub-second precision, no time zones, no leap-second representation.
expires_at = 0is the single exception, meaning "does not expire", and is permitted only where §8 says so.
Timestamps are asserted by the signer, not by the server. A signer may lie about them. They are used for expiry, not to establish an ordering between different signers' statements; see §13.
5. Values
Claim values and approval payload values are typed. Each value is one tag byte followed by a type-dependent body.
| Tag | Type | Body |
|---|---|---|
0x00 |
null | none |
0x01 |
false | none |
0x02 |
true | none |
0x03 |
string | enc_string |
0x04 |
number | enc_bytes of the canonical decimal token (ASCII) |
0x05 |
reserved (bytes) | — |
0x06 |
reserved (array) | — |
0x07 |
reserved (map) | — |
0x08–0xff |
reserved | — |
false and true have distinct tags rather than one boolean tag with a
payload byte, so there is no invalid third spelling of a boolean.
Reserved tags must be rejected, not skipped. A decoder that ignored an unknown value would compute a different meaning for the object than a decoder that understood it, while both would see a valid signature. See §12.2.
5.1 Number canonicalization
Numbers are carried as decimal text, not as binary floating point.
A JSON number is an arbitrary-precision decimal literal. Converting it to an IEEE-754 double loses precision above 2^53 and makes the signed bytes depend on the implementation's parsing and rounding. Text has one spelling per value and no precision cliff.
An implementation takes the number's exact source token (as JSON supplies
it, e.g. via json.Number in Go or parse_float=str in Python) and reduces
it as follows.
Accepted input grammar (JSON number, RFC 8259):
-? ( 0 | [1-9][0-9]* ) ( "." [0-9]+ )? ( [eE] [+-]? [0-9]+ )?
Canonicalization:
- Reject any token not matching the grammar, or longer than 64 bytes. This
rejects
+1,01,1.,.5,1e,0x10,NaN,Infinity,1_000and anything with surrounding whitespace. - Reject an exponent with more than 4 digits.
- Compute
mantissaandscalesuch that the value issign * mantissa * 10^scale. - If
mantissa == 0, the canonical form is0. This maps-0,0.0and0e10all to0; negative zero is not representable. - While
scale < 0andmantissais divisible by 10, divide and incrementscale. This strips trailing fractional zeros. - Render as plain decimal with no exponent: an optional
-, then digits with no leading zero (except a single0before a decimal point), then, if the fractional part is non-empty,.and the fractional digits. - Reject if the integer part exceeds 32 digits, the fractional part exceeds 18 digits, or the result exceeds 52 bytes.
The canonical token is then encoded as 0x04 || enc_bytes(ascii).
Worked examples, all present in the frozen vectors:
| Input | Canonical | Input | Canonical | |
|---|---|---|---|---|
0 |
0 |
1.5 |
1.5 |
|
-0 |
0 |
1.50 |
1.5 |
|
0.0 |
0 |
1e-1 |
0.1 |
|
0e10 |
0 |
1e-3 |
0.001 |
|
1 |
1 |
1.23e2 |
123 |
|
1.0 |
1 |
1e18 |
1000000000000000000 |
|
1e0 |
1 |
-1e-18 |
-0.000000000000000001 |
|
1.000 |
1 |
12345678901234567890 |
12345678901234567890 |
|
1e1 |
10 |
999999999999999999999999 |
999999999999999999999999 |
Rejected: 1e99999 (exponent digits), 1 followed by 40 zeros (integer
digits), 0.0000000000000000011 (fractional digits).
Implementations must not round-trip a number through a binary float.
6. Maps and limits
6.1 Map encoding
enc_map(m) = uvarint(count) || ( enc_bytes(key) || value )*
Entries are sorted by raw key bytes, unsigned bytewise ascending
(memcmp order). This is not locale-aware, not code-point-aware beyond what
UTF-8 already gives, and not case-insensitive. Since keys are restricted to
ASCII (§6.2), bytewise order equals code-point order.
- Duplicate keys are rejected, on encode and on decode. Accepting them would leave "which one wins" to the implementation.
- A decoder must verify that the entries it reads are strictly ascending. An object whose map is out of order is invalid even though it parses, because otherwise two byte strings would encode the same map.
- The empty map is valid where §8 permits it, and encodes as
0x00.
6.2 Key grammar
[a-z][a-z0-9]*([._-][a-z0-9]+)*
Lowercase ASCII letters and digits, with ., _ or - as separators; must
start with a letter; no leading, trailing or repeated separators.
This is a lexical rule with no semantics attached. The protocol never
interprets a key. example.flag, a.first and anything.at.all are byte
strings to every component of the system (INV-5). The restriction exists so
that keys are unambiguous, sort predictably, and cannot carry homoglyphs or
bidirectional overrides.
6.3 Size limits
Limits are part of the format. An object exceeding any of them is invalid, so every implementation refuses the same inputs and a signer cannot create an object that some verifiers accept and others reject.
| Field | Limit |
|---|---|
| map key | 128 bytes |
| string value | 512 bytes |
| canonical number token | 52 bytes |
| entries per map | 32 |
action |
128 bytes |
message |
256 bytes |
reason |
256 bytes |
alias |
64 bytes |
scope |
32 bytes |
audience |
128 bytes |
| nonce | exactly 16 bytes |
| hash / challenge | exactly 32 bytes |
| public key | exactly 32 bytes |
| signature | exactly 64 bytes |
Whole-object TCE limits:
| Object | Max bytes |
|---|---|
IdentityRegistration |
1024 |
Claim |
4096 |
Revocation |
1024 |
ApprovalRequest |
8192 |
ApprovalResponse |
1024 |
AuthAssertion |
1024 |
DelegationClaim |
2048 |
KeyRotationRequest |
1024 |
KeyRotationConfirm |
1024 |
These are protocol limits. A server may impose stricter operational limits and reject with a resource status; that is a separate mechanism and does not make the object invalid elsewhere.
7. Object IDs and signatures
7.1 Content addressing
object_id = SHA-256(tce_bytes)
32 bytes, rendered as lowercase hex in JSON. claim_id, request_id and
revocation_id are all object IDs.
Consequences:
- An ID is a content address. Two objects with the same ID are the same bytes. An object's ID cannot be chosen independently of its content.
- IDs are never database identifiers, and no row id or sequence number is ever used as a security identifier (INV-3).
- The
request_hashcheck in anApprovalResponseis a byte comparison of 32 bytes, with no parsing involved (INV-4).
7.2 Signing
signature = Ed25519_Sign(private_key, tce_bytes)
- Pure Ed25519 as in RFC 8032 §5.1, the algorithm implemented by Go's
crypto/ed25519and by libsodium'scrypto_sign_detached. Not Ed25519ph, not Ed25519ctx, no context string. - The signature covers the TCE bytes, not the object ID. The hash exists for identification and reference only. Signing a hash instead of the message would add nothing and would make the scheme depend on collision resistance in a second place.
- Ed25519 signing is deterministic: the same key over the same message always produces the same 64 bytes. Implementations may rely on this when comparing objects.
- The signature is not part of the TCE bytes (§9).
7.3 Verification
A verifier must perform all of the following, in this order, and must treat any failure as total:
- Enforce the transport size limit before reading the body.
- Decode the TCE bytes with a strict decoder (§12.3): correct magic, known object tag, known version, every field present, all limits honoured, maps sorted and duplicate-free, uvarints minimal, no trailing bytes.
- Validate the public key of the relevant identity field per ADDRESS.md.
- Check
Ed25519_Verify(pubkey, tce_bytes, signature). - Check the object-specific rules in §8 (timestamps, lifetime bounds,
request_hashbinding, responder identity). - Apply the caller's own trust policy and authorization logic, which the protocol does not supply (INV-5).
Notes:
- Step 2 must precede step 4 conceptually and must be enforced regardless of the outcome of step 4. A valid signature over a malformed object is still a rejected object.
- Verification takes the received bytes. Never re-encode a decoded object and verify the result.
crypto/ed25519.Verifyperforms no key validation, which is why step 3 is mandatory and separate. See ADDRESS.md for the universal forgery this prevents.- Signature malleability: Ed25519 verification as specified in RFC 8032
rejects a signature whose
Scomponent is not reduced modulo the group order, soS + Lis not a second valid signature. This was verified experimentally against the implementation in use. Implementations must not disable this check.
8. Objects
Field order is exactly as listed. Every field is always present; there are
no optional fields and no field is omitted when empty. An empty string encodes
as 0x00, an empty map as 0x00, and expires_at = 0 where permitted means
"no expiry".
8.1 IdentityRegistration — tag 0x01
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | identity |
identity | the key signing this object |
| 2 | alias |
string, ≤64 | self-asserted, may be empty |
| 3 | created_at |
timestamp |
Registration is a convenience for discovery, not a prerequisite. An identity exists because its key exists; nothing in the protocol requires it to be registered anywhere.
The alias appears here and in no other object. Signing it makes the self-assertion tamper-evident, while it remains non-authoritative: not unique, not verified, and never consulted when verifying any other object (INV-7). User interfaces must never display an alias without its address.
8.2 Claim — tag 0x02
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | issuer |
identity | signer |
| 2 | subject |
identity | may equal issuer (self-claim) |
| 3 | claims |
map | ≥1 entry, ≤32 |
| 4 | created_at |
timestamp | |
| 5 | expires_at |
timestamp or 0 |
0 = no expiry; otherwise > created_at |
| 6 | serial |
uvarint | see §13.2 |
| 7 | nonce |
16 bytes | see §13.3 |
Semantics: the issuer asserts that, for the subject, each key has the given value. Nothing more. No component of the trust system decides whether the issuer is entitled to say it, or what the statement means.
8.3 Revocation — tag 0x03
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | issuer |
identity | must equal the issuer of the target claim |
| 2 | claim_id |
32 bytes | object ID of the claim being revoked |
| 3 | reason |
string, ≤256 | free text, may be empty, no semantics |
| 4 | created_at |
timestamp | |
| 5 | nonce |
16 bytes |
A revocation is a signed statement, exactly like a claim. A verifier must
check that revocation.issuer equals the target claim's issuer; a revocation
signed by anyone else is meaningless.
Revoked claims are retained alongside their revocation rather than deleted, so that a consumer can verify the withdrawal itself. Absence of data is not evidence: a consumer must treat a missing claim as unknown, never as revoked or denied, because a hostile or broken relay can withhold anything (INV-1).
8.4 ApprovalRequest — tag 0x04
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | sender |
identity | signer |
| 2 | recipient |
identity | the only identity that may answer |
| 3 | action |
string, ≤128 | opaque |
| 4 | payload |
map, ≤32 | opaque, may be empty |
| 5 | message |
string, ≤256 | human-readable, may be empty |
| 6 | created_at |
timestamp | |
| 7 | expires_at |
timestamp | > created_at, at most 60 s later |
| 8 | nonce |
16 bytes |
request_id = SHA-256(tce_bytes).
The maximum lifetime of 60 seconds is part of the format, so an over-long request is invalid everywhere rather than merely refused by one server.
action and payload are opaque. Neither the relay nor this specification
assigns them meaning.
message is what a human will read when approving. It is signed, so it
cannot be altered in transit, but it is written by the sender and a hostile
sender can make it say anything. A recipient's client must display the
sender's address alongside it and must not present the message as though the
relay endorsed it.
8.5 ApprovalResponse — tag 0x05
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | request_hash |
32 bytes | object ID of the exact request |
| 2 | responder |
identity | must equal the request's recipient |
| 3 | decision |
uvarint | 0 = deny, 1 = allow; others invalid |
| 4 | created_at |
timestamp | |
| 5 | nonce |
16 bytes |
request_hash is first, before the responder, because it is the field that
gives the object its meaning; a response is unintelligible without it.
Mandatory checks, in addition to the signature:
SHA-256(received_request_tce) == response.request_hash.response.responder == request.recipient.request.created_at <= response.created_at <= request.expires_at, with the clock-skew allowance of §13.1.- The request has not already been answered (§13.3).
Check 1 is why the response commits to a hash rather than to a sender-chosen
request_id. If the response named an identifier the sender controlled, a
sender could present a different request body under the same identifier and
reuse the signed decision. Binding to the content hash makes that impossible
(INV-4).
There is deliberately no API that verifies a response on its own. A verifier must hold the request.
The relay never creates a response and never converts silence into a decision. Absence of a response means only that no response arrived.
8.6 AuthAssertion — tag 0x06
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | identity |
identity | signer |
| 2 | challenge |
32 bytes | server-issued, single use |
| 3 | scope |
string, ≤32 | e.g. ws, inbox |
| 4 | audience |
string, ≤128 | server hostname, e.g. trust.n1ko.dev |
| 5 | created_at |
timestamp |
Used to prove possession of a private key when opening a WebSocket or reading an inbox. It is a transport capability only: it authenticates a connection and grants nothing.
audience is signed so that an assertion produced for one server cannot be
replayed to another. Claims and approvals carry no audience, because they are
global statements intended to be portable between relays; an assertion is
inherently local to one server.
The challenge is generated by the server with a CSPRNG, is 32 bytes, is valid once, and expires. A server must never accept a challenge it did not issue.
8.7 DelegationClaim — tag 0x07
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | granter |
identity | signer; lends its authority |
| 2 | grantee |
identity | may now speak under the grant |
| 3 | predicates |
map, ≥1, ≤32 | exact claim keys covered; every value must be true |
| 4 | max_depth |
uvarint | further re-delegation hops permitted below this grant |
| 5 | created_at |
timestamp | |
| 6 | expires_at |
timestamp or 0 |
0 = no expiry; otherwise > created_at |
| 7 | serial |
uvarint | supersession, same rule as claims (§13.2) |
| 8 | nonce |
16 bytes |
Semantics: the granter asserts that grantee may issue claims whose
predicate is one of predicates, re-delegating at most max_depth further
times. The protocol establishes who granted what to whom; whether a
consumer accepts a chain is policy, resolved in the verifier by walking from
the claim's issuer up to a root it already trusts (DELEGATION.md).
Mandatory checks beyond the signature:
- Every predicate value is the boolean
true. A delegation covers a key or it does not; any other spelling would let two implementations disagree about coverage while both saw a valid signature. - Timing follows the claim rules:
ValidateCurrentwith the §13.1 skew.
Revocation mirrors claims: a Revocation signed by the granter targeting
this object's ID withdraws the grant (VerifyRevocationOfDelegation). A
revocation signed by anyone else is meaningless.
Matching is exact. Prefix and wildcard delegation are deliberately absent:
user. must never silently cover users.admin.
8.8 KeyRotationRequest — tag 0x08
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | successor |
identity | signer; the incoming key |
| 2 | predecessor |
identity | the key being succeeded; only its consent counts |
| 3 | created_at |
timestamp | |
| 4 | expires_at |
timestamp | > created_at, at most 60 s later (§8.4 bound) |
Semantics: the successor claims succession from predecessor. Alone it proves nothing about consent — anyone can name any predecessor.
8.9 KeyRotationConfirm — tag 0x09
| # | Field | Encoding | Notes |
|---|---|---|---|
| 1 | rotation_hash |
32 bytes | object ID of the exact request (INV-4) |
| 2 | created_at |
timestamp | |
| 3 | nonce |
16 bytes |
Mandatory checks beyond the signature:
- The confirm verifies under the request's predecessor key.
SHA-256(received request TCE) == rotation_hash.request.created_at ≤ confirm.created_at ≤ request.expires_at, with the §13.1 skew allowance, exactly as approvals.
There is deliberately no standalone acceptance of a confirm, just as for
approval responses: the verifier must hold both objects
(VerifyKeyRotationConfirm). Freshness across long horizons is policy, not
wire format (Policy.RotationMaxAge in ROTATION.md).
9. Fields deliberately excluded from TCE
| Excluded | Why |
|---|---|
signature |
An object cannot commit to its own signature. The signature is transported beside the TCE bytes, never inside them. |
object_id / claim_id of self |
Derived from the bytes; including it would be circular. |
alias of issuer, subject, sender, recipient or responder |
Aliases are self-asserted and non-authoritative. If an alias were signed into a claim, a verifier might treat it as attested, and alias spoofing would become a protocol vulnerability instead of a presentation concern (INV-7). |
| Server-assigned identifiers, row ids, sequence numbers | Security identity is cryptographic. A relay must not be able to influence an object's meaning (INV-1, INV-3). |
| Receipt time, storage time, delivery status | Observations by a relay, not statements by the signer. Including them would let a relay alter signed content. |
audience on claims, revocations and approvals |
These are global statements. Binding them to one relay would prevent a consumer from verifying an object fetched from a mirror. |
| Transport metadata: IP addresses, user agents, API keys | Not part of any statement anyone signed. |
10. Version 1 wire format (JSON transport)
{
"tce": "<base64 standard, with padding, of the canonical bytes>",
"signature": "<base64 of 64 bytes>",
"object": {
"type": "claim",
"version": 1,
"issuer": "trust1q...",
"subject": "trust1q...",
"claims": { "example.flag": true },
"created_at": 1700000000,
"expires_at": 1700086400,
"serial": 1,
"nonce": "000102030405060708090a0b0c0d0e0f"
},
"object_id": "<lowercase hex of SHA-256(tce)>"
}
tceandsignatureare authoritative.objectandobject_idare a convenience view.- A verifier must decode
tce, verify the signature over those bytes, and read any field it needs from those bytes. It must not trustobject, and must not re-encodeobjectto reconstructtce. - A server must recompute
object_idfromtceand ignore any supplied value. - Binary fields in the JSON view are lowercase hex;
tceandsignatureare base64 because they are larger.
11. Reference: complete byte breakdown
claim/boolean from the frozen vectors, 134 bytes:
off bytes field
0 74727573742e6e316b6f2e6465762f7463652f3100 magic
21 02 object tag: Claim
22 01 version = 1
23 00 issuer: address version 0
24 20 issuer: key length 32
25 8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c issuer: public key
57 00 subject: address version 0
58 20 subject: key length 32
59 8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394 subject: public key
91 01 claims: 1 entry
92 0c key length 12
93 6578616d706c652e666c6167 key "example.flag"
105 02 value tag: true
106 80e2cfaa06 created_at = 1700000000
111 8085d5aa06 expires_at = 1700086400
116 01 serial = 1
117 10 nonce length 16
118 000102030405060708090a0b0c0d0e0f nonce
object_id = d3f3e2140658... (full value in the vector file)
signature = 86311b1f5416... (Ed25519 over all 134 bytes above)
12. Versioning and unknown data
12.1 Version negotiation
The framing version is fixed by the magic and the object version by the
version uvarint. A decoder that does not implement a version it encounters
must reject the object. It must never attempt a partial or best-effort parse.
12.2 Unknown tags and reserved values
Unknown object tags, unknown value tags and reserved value tags are all rejected.
This is deliberate and is the opposite of the "ignore what you do not understand" rule common in extensible formats. In a signed protocol, skipping an unrecognised field means two implementations compute different meanings for the same signed bytes, and both see a valid signature. An old verifier could then approve an object whose actual content it never saw. Fail-closed is the only safe behaviour.
12.3 Strict decoding
A conforming decoder rejects, at minimum:
| Condition | Vector |
|---|---|
| empty input | empty |
| truncated or wrong magic | magic_truncated, magic_wrong_version |
| unknown object tag | unknown_object_tag |
object tag 0x00 |
object_tag_zero |
| unknown object version | — |
| input ends mid-field | truncated_body |
| any byte after the last field | trailing_byte |
| non-minimal uvarint | non_minimal_uvarint |
| uvarint longer than 10 bytes, or overflowing 2^64−1 | — |
| length prefix exceeding remaining input | — |
| any field exceeding its §6 limit | — |
| fixed-width field with the wrong length | — |
| map keys not strictly ascending | unsorted_map_keys |
| duplicate map key | duplicate_map_key |
| map key not matching the §6.2 grammar | — |
| reserved or unknown value tag | reserved_value_tag |
| invalid UTF-8, or a control character in a string | — |
| non-canonical number token | see §5.1 |
| timestamp outside the §4.6 range | — |
| public key failing curve validation | — |
| approval lifetime exceeding 60 s | — |
decision other than 0 or 1 |
— |
12.4 Re-encoding
decode(encode(x)) == x and encode(decode(b)) == b must both hold. The
second is the important one: it states that the encoding is not malleable, and
that no two byte strings decode to the same object. Both are fuzz properties
in Stage 2b.
13. Time, ordering and replay
13.1 Clock skew
Timestamps come from the signer's clock and cannot be trusted absolutely. A
verifier should allow ±120 seconds of skew when checking whether an object
is currently valid, and should reject an object whose created_at is further
in the future than that allowance.
13.2 Supersession
serial lets an issuer replace an earlier claim about the same subject. For
two claims from the same issuer about the same subject with overlapping keys,
the one with the higher serial is the issuer's later statement.
This is guidance for consumers, not something a relay enforces. A relay does not decide which of two signed statements is "current"; it stores both. A consumer that needs a single answer applies its own rule, and should treat a missing higher serial as unknown rather than assuming it has seen everything.
serial does not replace revocation. Supersession changes a value;
revocation withdraws a statement.
13.3 Nonces and replay
Every claim, revocation and approval object carries a 16-byte nonce from a CSPRNG. Its purpose is to make otherwise identical objects distinct, so that two claims with the same content and timestamp have different object IDs.
Replay protection comes from the combination of:
- the object ID, which is unique per distinct byte string, so a relay can reject a resubmission by ID;
expires_at, which bounds how long an object is useful;- the nonce, tracked per issuer within the replay window;
- for approvals, the one-response-per-request rule: the first valid response wins and is immutable, so a second signed decision for the same request is rejected rather than overwriting the first.
A consumer must not assume the relay deduplicated anything. Each of the above checks is cheap and must be applied locally as well.
14. Frozen test vectors
testdata/vectors/tce_vectors.json contains, for each vector: the canonical
TCE bytes in hex, the byte length, the SHA-256 object ID, the signer's public
key and address, the Ed25519 signature, and the JSON view.
Two fixed parties are used throughout:
| Party | Seed | Address |
|---|---|---|
| NikoCraft | 01 × 32 |
trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75 |
| Niko | 02 × 32 |
trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s |
The seeds are deliberately trivial so that any implementation can reproduce the keys. They are test values and must never be used for anything.
The vector set covers:
| Vector | Exercises |
|---|---|
identity/nikocraft, identity/niko |
registration, alias encoding |
claim/boolean |
minimal claim, single boolean |
claim/all-value-types |
every value type, map sorting from unsorted input, expires_at = 0 |
revocation/boolean-claim |
revocation referencing a claim by content hash |
approval_request/ban |
opaque action and payload, 30 s lifetime |
approval_response/allow |
request_hash binding |
approval_response/deny |
differs from allow in one byte, yielding a different ID and signature |
auth_assertion/ws |
challenge, scope and audience binding |
delegation/minimal |
one exact predicate, max_depth 0 |
delegation/multi-predicate |
three predicates exercising map order, depth 2, no expiry |
key_rotation/request + key_rotation/confirm |
succession claim and its hash-bound consent |
The file also contains number_canonicalization (28 accepted tokens with
their canonical forms, 18 rejected tokens with reasons) and rejects
(malformed encodings a decoder must refuse).
Cross-implementation check performed
The vectors were produced by the Python reference implementation and then independently verified by a Go program that recomputed every SHA-256 object ID, verified every Ed25519 signature against the stated public key, confirmed that no signature verifies over mutated bytes, and confirmed that all object IDs are distinct: 9 vectors, 0 failures.
Stage 2b's Go implementation must reproduce every byte of this file. Any disagreement is a bug in the Go implementation, not in the vectors.