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

32 KiB
Raw Blame History

TCE — Trust Canonical Encoding, version 1

This document specifies the byte-exact representation of every signed object in the trust protocol. It is normative. An implementation in any language that follows it will produce identical bytes, identical object IDs and identical signatures.

Frozen test vectors accompany this document at testdata/vectors/tce_vectors.json, and an executable reference implementation at tools/reference/tce_reference.py.

Companion documents: INVARIANTS.md, ADDRESS.md.


1. Why a binary encoding rather than JSON

Signatures are computed over TCE bytes and never over JSON.

JSON has no canonical form. Key order, whitespace, escaping (/ vs \/, é vs \u00e9), and number spelling (1, 1.0, 1e0) all vary between libraries while denoting the same document. Signing a JSON document therefore means signing whichever spelling the local library happened to emit, and a verifier that re-serialises before checking will sometimes disagree. That is a correctness bug in the best case and a signature-stripping vulnerability in the worst.

TCE has exactly one encoding per value. It is length-prefixed rather than delimited, so no escaping exists and no parser lookahead is required. Every field is fixed in position and every length is explicit, so two different byte strings can never decode to the same object.

JSON remains the transport syntax. The wire form of an object carries the TCE bytes (base64) plus a decoded JSON view for humans and for debugging. A verifier must verify the TCE bytes it received and then, if it needs fields, decode them from those same bytes. It must never re-encode the JSON view and verify that. See §12.4.


2. Framing

Every TCE object is:

MAGIC || object_tag || version || field_1 || field_2 || ... || field_n
Element Size Value
MAGIC 21 bytes ASCII trust.n1ko.dev/tce/1 followed by 0x00
object_tag 1 byte see §3
version uvarint 1 for this specification

MAGIC in hex:

74 72 75 73 74 2e 6e 31 6b 6f 2e 64 65 76 2f 74 63 65 2f 31 00

The magic string is domain separation at the outermost level. It ensures that a TCE object can never be mistaken for, or reinterpreted as, a signed message from a different protocol that happens to share a key. The trailing 0x00 terminates the ASCII portion so that a longer magic in a future version cannot be a prefix of this one.

The 1 in the magic is the framing version and the version uvarint is the object version. They are separate: the framing version changes only if the encoding rules themselves change, while the object version changes when an object's field list changes. In version 1 they are both 1.

There is no length field for the object as a whole. The object ends when its last field ends, and any trailing byte is an error (§12.3).


3. Object tags (domain separation)

Tag Object Signed by §
0x00 permanently reserved, never valid
0x01 IdentityRegistration the key itself §8.1
0x02 Claim issuer §8.2
0x03 Revocation issuer of the target claim §8.3
0x04 ApprovalRequest sender §8.4
0x05 ApprovalResponse responder §8.5
0x06 AuthAssertion the asserting identity §8.6
0x070x7f reserved for future object types
0x800xff 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+D800U+DFFF) are rejected.
  • No C0 controls (U+0000U+001F), no U+007F, no C1 controls (U+0080U+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: 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)
0x080xff 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.
  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 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)

{
  "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.