- 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.
871 lines
35 KiB
Markdown
871 lines
35 KiB
Markdown
# 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": "<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^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.
|