- 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
8.5 KiB
Stage 2b Report — TCE wire codec (internal/tce) and protocol objects (internal/protocol)
Status: complete. All tests pass; the Go codec is cross-checked against the Python reference. Stop before Stage 3 (server policy evaluation) — nothing here touches §9–§11 semantics; those are out of scope for this stage.
Objective
Implement the exact binary TCE codec (§2–§6 of docs/PROTOCOL.md) and the six
protocol objects (§7–§8, §12) in Go 1.25: EncodeX/DecodeX/VerifyX, with
rule-level, golden, mutation, fuzz and invariant coverage, and a frozen
testdata/vectors/tce_vectors.json regression set.
Files changed (this stage)
internal/protocol/objects.go— 6 structs, unexportedtce/sigretention,TCE()/Signature()(copy) accessors,ErrNilsentinel.internal/protocol/encode.go—EncodeIdentity/Claim/Revocation/ApprovalRequest/ApprovalResponse/AuthAssertion.internal/protocol/decode.go— strictDecodeX(whole-object limit first, tag check,address.ValidatePubKeyon every pubkey,End()trailing guard, byte-copies so decoded buffers never alias input).internal/protocol/verify.go—VerifyX,VerifyApprovalResponse(binds request viarequest_hash, responder==recipient, timing window),VerifyRevocationOf,ValidateCurrent,ClaimStatusAt.internal/protocol/helpers_test.go— vector loader,signerFixtures,rejectEntry.TCEHexchanged to*string(distinguishes empty from absent).internal/protocol/{vectors,rules,verify,rejects,mutation,fuzz,invariants}_test.go.internal/tce/{primitives,vectors,fuzz,invariants}_test.go(existing codec, new fuzz + invariant coverage).
Canonical format (reaffirmed, per §8)
Field order fixed: identity(identity,alias,created_at) ·
claim(issuer,subject,claims,created_at,expires_at,serial,nonce) ·
revocation(issuer,claim_id,reason,created_at,nonce) ·
request(sender,recipient,action,payload,message,created_at,expires_at,nonce) ·
response(request_hash,responder,decision,created_at,nonce) ·
auth(identity,challenge,scope,audience,created_at).
DecodeX retains the received bytes in o.tce; VerifyX verifies the signature
over o.tce (property #2: "the bytes that arrived are the bytes that are
verified"). Encoder ignores any tce/sig set by the caller.
Test inventory & results
| Suite | Covers | Result |
|---|---|---|
tce primitives/fuzz |
§2,§4.1,§4.3,§6.2,§4.6,§5,§6.1, fixed/trailing/ID/injective | PASS |
tce vectors |
28 accept + 18 reject number vectors from frozen file + idempotence | PASS |
tce invariants |
no encoding/json, no signing, allowed deps |
PASS |
protocol vectors |
9 golden (pubkey/address match seed, object_id, len, signature verify, encode(decode)==, revocation→claim & response→request bindings) |
PASS |
protocol rules |
per-object §8 limits & reject paths (empty/expiry/nonce/33-entry/whole-limit/degenerate-key/alias/message/action/reason/scope/audience/lifetime) | PASS |
protocol verify |
claim sig+foreign/short/empty/tampered/wrong-type; response request_hash/responder/early/late/window; auth exact/empty/affix; revocation binding; ValidateCurrent/ClaimStatusAt |
PASS |
protocol rejects |
8 exact tce_hex rejects + unsorted/dup/value-tag (hand-patched bytes) |
PASS |
protocol mutation |
TestMutationSweep (flip every byte of TCE ×{0x01,0x80} + every sig bit → verify fails; baseline verifies) + TestDenyAndAllowDifferInOneByte |
PASS |
protocol fuzz |
FuzzDecodeIsTotalAndNonMalleable (decode totality + encode(decode(b))==b) and FuzzClaimBuildRoundTrip (decode(encode(x))==x, ID-stable) |
PASS (≈545k / ≈23k execs, 8s each) |
protocol invariants |
no encoding/json, no signing, allowed deps |
PASS |
go test ./... -count=1: all packages OK. go vet ./...: clean.
Coverage
internal/tce: 85.3% of statements (was 74.7% — addednumber_test.gocovering §5.1CanonicalNumber/IsCanonicalNumber,id_test.goforIDaccessors/ParseID/IDFromBytes/MarshalText/UnmarshalText, andvalue_test.goforValueconstructors/accessors/Equal/GoString).internal/protocol: 91.4% of statements (was 89.5% — addedaccessors_test.goforDecision.String, everySignature()accessor,ValidateCurrent/ClaimStatusAtclock-skew boundaries, andVerifyRevocationOfissuer/claim-ID binding errors).
Hardening pass (fuzz depth, OSS-Fuzz, regression corpus)
- Fuzz caught a real test bug.
FuzzStringValidationfound a seed where a long (≥128-byte) valid string failed the round-trip assertion. The assertion wrongly assumed a single-byte length prefix; the encoder uses a uvarint prefix, which is multi-byte for long strings. The codec was correct — the test was fixed to decode the uvarint prefix (internal/tce/fuzz_test.go). The failing input is committed asinternal/tce/testdata/fuzz/FuzzStringValidation/f921751fe02821d6. - All six fuzz targets run stable under extended fuzzing (≥20s each,
millions of execs, no crashes/hangs):
tceFuzzUvarint,FuzzDecodePrimitives,FuzzStringValidation;protocolFuzzDecodeIsTotalAndNonMalleable(decode totality +encode(decode(b))==b),FuzzClaimBuildRoundTrip(decode(encode(x))==x, ID-stable). The live corpus is persisted in the Go fuzz cache ($GOCACHE/fuzz) and replayed on everygo test -fuzz; reviewable seed inputs are committed viaf.Add(golden vectors + edge cases: multi-byte string prefix, UTF-8, multi-entry maps, large numbers). - OSS-Fuzz / go-fuzz harness. Added
internal/tce/fuzz.goandinternal/protocol/fuzz.gounder//go:build gofuzz, each exposing the standardfunc Fuzz(data []byte) intentry point asserting the §12.4 totality/injection properties. Both compile cleanly withgo build -tags gofuzz ./internal/tce ./internal/protocol(verified). These are excluded from normalgo testbuilds, so they do not affect the unit suite. To run under OSS-Fuzz, build withgo-fuzz-build/go-fuzz(thegofuzztag), which instruments the same code paths the unit fuzz targets exercise.
Cross-check vs Python reference
python3 tools/reference/tce_reference.py regenerated and the output was
byte-for-byte identical to testdata/vectors/tce_vectors.json (canonical
bytes, object_id_hex, signature_hex). The reference signs over the raw TCE
bytes and derives object_id as SHA256(tce) — exactly what the Go VerifyX
and ComputeID assume — so the golden vectors are authoritative and the Go
tests exercise them directly. No drift between implementations.
Deviations / interpretation notes (must survive into Stage 3)
- Response timing window. §8.5/§13.1 delimit an approval's validity only
by
request.created_at/request.expires_at. Because responder and requester clocks are independent, the implemented window isrequest.created_at - MaxClockSkew <= response.created_at <= request.expires_at + MaxClockSkewwith a singleMaxClockSkew = 120. This is a tolerance interpretation, not a protocol change. Surfaced here so Stage 3 clock handling stays consistent. - Public-key validation.
crypto/ed25519.Verifydoes not reject small-order points;decode.go/encode.govalidate every pubkey viaaddress.ValidatePubKey(filippo.io/edwards25519) before any signature step (non-negotiable #3).crypto/ed25519is imported byprotocolfored25519.Verifyonly; signing (Sign/NewKeyFromSeed/GenerateKey) lives solely ininternal/identity/signer. - No JSON in the codec.
internal/tceandinternal/protocoldo not importencoding/json(enforced byTestNoJSONImport). Thejsonfield in vectors is reference-only and never parsed by the codec. - Request/response coupling.
VerifyResponsedoes not exist standalone (non-negotiable #3): a response is only meaningful bound to its request viaVerifyApprovalResponse(requestTCE, requestSig, responseTCE, responseSig). - Audience is exact, constant-time, and an empty expected audience is
rejected (
ErrEmptyAudience).
Not done (future stages)
- Stage 3 server policy engine (§9 proof satisfaction, §10 proof search, §11 boolean/threshold/rate-limit evaluation). This stage ships only the codec + cryptographic/structural verification + frozen regression vectors.
- Higher fuzz durations / OSS-Fuzz harness wiring (current runs are 8s smoke fuzzes; corpus is seeded from the 9 golden objects).
- End-to-end "deny reason" /
get_claims-style query APIs.