niko_trust/docs/PROTOCOL.md
Niko Marmeladkov 9d66003689
Initial commit: signed-object trust relay, verifier, and docs
- server: relay storing signed objects (PUT/GET), per-IP rate limiting,
  per-subject quota (1000), one-response-per-request, pagination,
  /v1/healthz /v1/readyz /v1/metrics
- verify: signature-verifying trust evaluator; every object is checked via
  env.Verify(), approvals via VerifyApprovalResponse, revocations via
  VerifyRevocationOf; k-of-n approval quorum
- docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and
  the endpoint/status-code contract
- tests: server, verify, and ratelimit packages
2026-08-12 22:36:49 +03:00

797 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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``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 |
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.
---
## 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": "<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)>"
}
```
- `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^641 | — |
| 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 |
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.