# 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](INVARIANTS.md), [ADDRESS.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 `0x00` is a longer spelling of a shorter value and **must be rejected**. For example `1` is `01`; the sequence `81 00` also 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`), no `U+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+FEFF` is 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](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 = 0` is 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: 1. Reject any token not matching the grammar, or longer than 64 bytes. This rejects `+1`, `01`, `1.`, `.5`, `1e`, `0x10`, `NaN`, `Infinity`, `1_000` and anything with surrounding whitespace. 2. Reject an exponent with more than 4 digits. 3. Compute `mantissa` and `scale` such that the value is `sign * mantissa * 10^scale`. 4. If `mantissa == 0`, the canonical form is `0`. This maps `-0`, `0.0` and `0e10` all to `0`; negative zero is not representable. 5. While `scale < 0` and `mantissa` is divisible by 10, divide and increment `scale`. This strips trailing fractional zeros. 6. Render as plain decimal with no exponent: an optional `-`, then digits with no leading zero (except a single `0` before a decimal point), then, if the fractional part is non-empty, `.` and the fractional digits. 7. 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_hash` check in an `ApprovalResponse` is 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/ed25519` and by libsodium's `crypto_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: 1. Enforce the transport size limit before reading the body. 2. 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. 3. Validate the public key of the relevant identity field per [ADDRESS.md](ADDRESS.md). 4. Check `Ed25519_Verify(pubkey, tce_bytes, signature)`. 5. Check the object-specific rules in §8 (timestamps, lifetime bounds, `request_hash` binding, responder identity). 6. 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.Verify` performs no key validation, which is why step 3 is mandatory and separate. See [ADDRESS.md](ADDRESS.md) for the universal forgery this prevents. - Signature malleability: Ed25519 verification as specified in RFC 8032 rejects a signature whose `S` component is not reduced modulo the group order, so `S + L` is 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: 1. `SHA-256(received_request_tce) == response.request_hash`. 2. `response.responder == request.recipient`. 3. `request.created_at <= response.created_at <= request.expires_at`, with the clock-skew allowance of §13.1. 4. 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](DELEGATION.md)). Mandatory checks beyond the signature: 1. 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. 2. Timing follows the claim rules: `ValidateCurrent` with 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: 1. The confirm verifies under the request's **predecessor** key. 2. `SHA-256(received request TCE) == rotation_hash`. 3. `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](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) ```json { "tce": "", "signature": "", "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": "" } ``` - `tce` and `signature` are authoritative. `object` and `object_id` are 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 trust `object`, and must not re-encode `object` to reconstruct `tce`. - A server must recompute `object_id` from `tce` and ignore any supplied value. - Binary fields in the JSON view are lowercase hex; `tce` and `signature` are 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.