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
This commit is contained in:
Niko Marmeladkov 2026-08-12 22:36:49 +03:00
commit 9d66003689
Signed by: Niko
GPG key ID: E3B955F9442D44E3
71 changed files with 14538 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
# Build artifacts
/client
/server
*.exe
*.test
# Editor / OS noise
*.swp
.DS_Store

129
REPORT_STAGE2B.md Normal file
View file

@ -0,0 +1,129 @@
# 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, unexported `tce`/`sig` retention,
`TCE()`/`Signature()` (copy) accessors, `ErrNil` sentinel.
- `internal/protocol/encode.go``EncodeIdentity/Claim/Revocation/ApprovalRequest/ApprovalResponse/AuthAssertion`.
- `internal/protocol/decode.go` — strict `DecodeX` (whole-object limit first,
tag check, `address.ValidatePubKey` on every pubkey, `End()` trailing guard,
byte-copies so decoded buffers never alias input).
- `internal/protocol/verify.go``VerifyX`, `VerifyApprovalResponse` (binds
request via `request_hash`, responder==recipient, timing window),
`VerifyRevocationOf`, `ValidateCurrent`, `ClaimStatusAt`.
- `internal/protocol/helpers_test.go` — vector loader, `signerFixtures`,
`rejectEntry.TCEHex` changed 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% — added `number_test.go`
covering §5.1 `CanonicalNumber`/`IsCanonicalNumber`, `id_test.go` for
`ID` accessors/`ParseID`/`IDFromBytes`/`MarshalText`/`UnmarshalText`, and
`value_test.go` for `Value` constructors/accessors/`Equal`/`GoString`).
- `internal/protocol`: 91.4% of statements (was 89.5% — added
`accessors_test.go` for `Decision.String`, every `Signature()` accessor,
`ValidateCurrent`/`ClaimStatusAt` clock-skew boundaries, and
`VerifyRevocationOf` issuer/claim-ID binding errors).
## Hardening pass (fuzz depth, OSS-Fuzz, regression corpus)
- **Fuzz caught a real test bug.** `FuzzStringValidation` found 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 as
`internal/tce/testdata/fuzz/FuzzStringValidation/f921751fe02821d6`.
- **All six fuzz targets run stable** under extended fuzzing (≥20s each,
millions of execs, no crashes/hangs): `tce` `FuzzUvarint`,
`FuzzDecodePrimitives`, `FuzzStringValidation`; `protocol`
`FuzzDecodeIsTotalAndNonMalleable` (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 every
`go test -fuzz`; reviewable seed inputs are committed via `f.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.go` and
`internal/protocol/fuzz.go` under `//go:build gofuzz`, each exposing the
standard `func Fuzz(data []byte) int` entry point asserting the §12.4
totality/injection properties. Both compile cleanly with
`go build -tags gofuzz ./internal/tce ./internal/protocol` (verified). These
are excluded from normal `go test` builds, so they do not affect the unit
suite. To run under OSS-Fuzz, build with `go-fuzz-build`/`go-fuzz` (the
`gofuzz` tag), 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)
1. **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 is
`request.created_at - MaxClockSkew <= response.created_at <= request.expires_at + MaxClockSkew`
with a single `MaxClockSkew = 120`. This is a *tolerance interpretation*, not a
protocol change. Surfaced here so Stage 3 clock handling stays consistent.
2. **Public-key validation.** `crypto/ed25519.Verify` does not reject small-order
points; `decode.go`/`encode.go` validate every pubkey via
`address.ValidatePubKey` (filippo.io/edwards25519) before any signature step
(non-negotiable #3). `crypto/ed25519` is imported by `protocol` for
`ed25519.Verify` only; signing (`Sign`/`NewKeyFromSeed`/`GenerateKey`) lives
solely in `internal/identity/signer`.
3. **No JSON in the codec.** `internal/tce` and `internal/protocol` do not import
`encoding/json` (enforced by `TestNoJSONImport`). The `json` field in vectors
is reference-only and never parsed by the codec.
4. **Request/response coupling.** `VerifyResponse` does not exist standalone
(non-negotiable #3): a response is only meaningful bound to its request via
`VerifyApprovalResponse(requestTCE, requestSig, responseTCE, responseSig)`.
5. **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.

615
cmd/client/main.go Normal file
View file

@ -0,0 +1,615 @@
// Command client is a minimal trust relay client: it generates keys, publishes
// signed objects, queries the relay, and performs the auth handshake. It is the
// "how a client uses the protocol" reference; no network logic lives in the
// codec packages.
package main
import (
"bytes"
"encoding/base64"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
"git.n1ko.dev/Niko/niko_trust/internal/verify"
)
func main() {
if len(os.Args) < 2 {
log.Fatal("usage: client <keygen|publish-claim|get|claims|auth> [flags]")
}
switch os.Args[1] {
case "keygen":
cmdKeygen(os.Args[2:])
case "publish-claim":
cmdPublishClaim(os.Args[2:])
case "publish-request":
cmdPublishRequest(os.Args[2:])
case "respond":
cmdRespond(os.Args[2:])
case "revoke":
cmdRevoke(os.Args[2:])
case "get":
cmdGet(os.Args[2:])
case "claims":
cmdClaims(os.Args[2:])
case "requests":
cmdRequests(os.Args[2:])
case "responses":
cmdResponses(os.Args[2:])
case "revocations":
cmdRevocations(os.Args[2:])
case "auth":
cmdAuth(os.Args[2:])
case "verify":
cmdVerify(os.Args[2:])
default:
log.Fatalf("unknown command %q", os.Args[1])
}
}
func loadSigner(path string) *signer.Signer {
raw, err := os.ReadFile(path)
if err != nil {
log.Fatalf("read seed: %v", err)
}
seed, err := hex.DecodeString(strings.TrimSpace(string(raw)))
if err != nil {
log.Fatalf("bad seed: %v", err)
}
s, err := signer.FromSeed(seed)
if err != nil {
log.Fatalf("signer: %v", err)
}
return s
}
func cmdKeygen(args []string) {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
out := fs.String("out", "", "write hex seed to this file (default stdout)")
fs.Parse(args)
s, err := signer.Generate()
if err != nil {
log.Fatal(err)
}
if *out != "" {
if err := os.WriteFile(*out, []byte(hex.EncodeToString(s.Seed())+"\n"), 0o600); err != nil {
log.Fatal(err)
}
fmt.Printf("address %s\nseed written to %s\n", s.Address().String(), *out)
return
}
fmt.Printf("address %s\nseed %s\n", s.Address().String(), hex.EncodeToString(s.Seed()))
}
func cmdPublishClaim(args []string) {
fs := flag.NewFlagSet("publish-claim", flag.ExitOnError)
seedPath := fs.String("seed", "", "issuer seed file")
subject := fs.String("subject", "", "subject trust address")
key := fs.String("key", "claim", "claim key (predicate)")
valType := fs.String("type", "bool", "bool|string|number")
val := fs.String("val", "true", "claim value")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
sub, err := address.Parse(*subject)
if err != nil {
log.Fatalf("subject: %v", err)
}
subPub := sub.PubKey()
var v tce.Value
switch *valType {
case "bool":
v = tce.Bool(*val == "true")
case "string":
v = tce.String(*val)
case "number":
v = tce.Number(*val)
default:
log.Fatalf("unknown value type %q", *valType)
}
c := &protocol.Claim{
Issuer: s.Public(),
Subject: subPub,
Claims: map[string]tce.Value{*key: v},
CreatedAt: uint64(time.Now().Unix()),
ExpiresAt: uint64(time.Now().Add(24 * time.Hour).Unix()),
Serial: 1,
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeClaim(c)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
postJSON(*url+"/v1/objects", env)
}
func cmdGet(args []string) {
fs := flag.NewFlagSet("get", flag.ExitOnError)
id := fs.String("id", "", "object id")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getJSON(*url + "/v1/objects/" + *id)
}
func cmdClaims(args []string) {
fs := flag.NewFlagSet("claims", flag.ExitOnError)
subject := fs.String("subject", "", "subject trust address")
token := fs.String("token", "", "session token (from auth)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
u := *url + "/v1/claims?subject=" + *subject
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
log.Fatal(err)
}
if *token != "" {
req.Header.Set("Authorization", "Bearer "+*token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func cmdAuth(args []string) {
fs := flag.NewFlagSet("auth", flag.ExitOnError)
seedPath := fs.String("seed", "", "client seed file")
scope := fs.String("scope", "read:claims", "requested scope")
audience := fs.String("audience", "", "server audience (default: fetched from /v1/config)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
// Resolve the audience the server expects assertions to be bound to.
aud := *audience
if aud == "" {
resp, err := http.Get(*url + "/v1/config")
if err != nil {
log.Fatalf("config: %v", err)
}
cfg := struct{ Audience string `json:"audience"` }{}
if resp.StatusCode >= 300 {
log.Fatalf("config: %s", resp.Status)
}
_ = json.NewDecoder(resp.Body).Decode(&cfg)
resp.Body.Close()
aud = cfg.Audience
}
if aud == "" {
log.Fatal("could not determine server audience")
}
// 1. Obtain a challenge from the server.
chRes := struct{ Challenge string `json:"challenge"` }{}
postJSONDecode(*url+"/v1/auth/challenge", nil, &chRes)
ch, err := hex.DecodeString(chRes.Challenge)
if err != nil {
log.Fatalf("challenge: %v", err)
}
// 2. Build and sign an AuthAssertion bound to the server's audience.
a := &protocol.AuthAssertion{
PubKey: s.Public(),
Challenge: ch,
Scope: *scope,
Audience: aud,
CreatedAt: uint64(time.Now().Unix()),
}
b, err := protocol.EncodeAuthAssertion(a)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(b)
env := &transport.Envelope{TCE: b, Signature: sig}
// 3. Present it; the server verifies and returns a session token.
out := struct {
SessionToken string `json:"session_token"`
Identity string `json:"identity"`
Scope string `json:"scope"`
}{}
postJSONDecode(*url+"/v1/auth/assert", env, &out)
fmt.Printf("session %s\nidentity %s\nscope %s\n", out.SessionToken, out.Identity, out.Scope)
}
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
log.Fatal(err)
}
return b
}
func postJSON(u string, v any) {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
log.Fatal(err)
}
resp, err := http.Post(u, "application/json", &buf)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func postJSONDecode(u string, v any, out any) {
var buf bytes.Buffer
if v != nil {
if err := json.NewEncoder(&buf).Encode(v); err != nil {
log.Fatal(err)
}
}
resp, err := http.Post(u, "application/json", &buf)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
log.Fatalf("%s: %s", resp.Status, string(body))
}
if out != nil {
if err := json.Unmarshal(body, out); err != nil {
log.Fatal(err)
}
}
}
func getJSON(u string) {
resp, err := http.Get(u)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func getAuthJSON(u, token string) {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
log.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Println(resp.Status)
fmt.Println(string(body))
}
func mustID(s string) tce.ID {
id, err := tce.ParseID(s)
if err != nil {
log.Fatalf("bad id %q: %v", s, err)
}
return id
}
func cmdPublishRequest(args []string) {
fs := flag.NewFlagSet("publish-request", flag.ExitOnError)
seedPath := fs.String("seed", "", "sender seed file")
recipient := fs.String("recipient", "", "recipient trust address")
action := fs.String("action", "", "opaque action the recipient approves")
message := fs.String("message", "", "human-readable message")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
rec, err := address.Parse(*recipient)
if err != nil {
log.Fatalf("recipient: %v", err)
}
now := uint64(time.Now().Unix())
req := &protocol.ApprovalRequest{
Sender: s.Public(),
Recipient: rec.PubKey(),
Action: *action,
Message: *message,
CreatedAt: now,
ExpiresAt: now + 30,
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeApprovalRequest(req)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
postJSON(*url+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
}
func cmdRespond(args []string) {
fs := flag.NewFlagSet("respond", flag.ExitOnError)
seedPath := fs.String("seed", "", "responder seed file")
request := fs.String("request", "", "request object id")
decision := fs.String("decision", "allow", "allow|deny")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
// Fetch the request to confirm we are its recipient.
env := struct {
TCE string `json:"tce"`
}{}
greq, err := http.Get(*url + "/v1/objects/" + *request)
if err != nil {
log.Fatalf("fetch request: %v", err)
}
body, _ := io.ReadAll(greq.Body)
greq.Body.Close()
if greq.StatusCode >= 300 {
log.Fatalf("fetch request: %s", greq.Status)
}
if err := json.Unmarshal(body, &env); err != nil {
log.Fatalf("request json: %v", err)
}
reqBytes, err := base64.StdEncoding.DecodeString(env.TCE)
if err != nil {
log.Fatalf("request tce: %v", err)
}
req, err := protocol.DecodeApprovalRequest(reqBytes)
if err != nil {
log.Fatalf("decode request: %v", err)
}
if transport.AddrOf(req.Recipient) != s.Address().String() {
log.Fatalf("this key is not the recipient of the request")
}
var dec protocol.Decision
switch *decision {
case "allow":
dec = protocol.Allow
case "deny":
dec = protocol.Deny
default:
log.Fatalf("unknown decision %q", *decision)
}
resp := &protocol.ApprovalResponse{
RequestHash: mustID(*request),
Responder: s.Public(),
Decision: dec,
CreatedAt: uint64(time.Now().Unix()),
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeApprovalResponse(resp)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
postJSON(*url+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
}
func cmdRevoke(args []string) {
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
seedPath := fs.String("seed", "", "issuer seed file")
claim := fs.String("claim", "", "claim object id to withdraw")
reason := fs.String("reason", "", "revocation reason")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
s := loadSigner(*seedPath)
rv := &protocol.Revocation{
Issuer: s.Public(),
ClaimID: mustID(*claim),
Reason: *reason,
CreatedAt: uint64(time.Now().Unix()),
Nonce: randBytes(tce.NonceSize),
}
tceBytes, err := protocol.EncodeRevocation(rv)
if err != nil {
log.Fatalf("encode: %v", err)
}
sig := s.Sign(tceBytes)
postJSON(*url+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
}
func cmdRequests(args []string) {
fs := flag.NewFlagSet("requests", flag.ExitOnError)
recipient := fs.String("recipient", "", "recipient trust address")
token := fs.String("token", "", "session token (from auth, scope read:requests)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getAuthJSON(*url+"/v1/requests?recipient="+*recipient, *token)
}
func cmdResponses(args []string) {
fs := flag.NewFlagSet("responses", flag.ExitOnError)
request := fs.String("request", "", "request object id")
token := fs.String("token", "", "session token (from auth, scope read:responses)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getAuthJSON(*url+"/v1/responses?request="+*request, *token)
}
func cmdRevocations(args []string) {
fs := flag.NewFlagSet("revocations", flag.ExitOnError)
claim := fs.String("claim", "", "claim object id")
token := fs.String("token", "", "session token (from auth, scope read:revocations)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
getAuthJSON(*url+"/v1/revocations?claim="+*claim, *token)
}
// rawEnvelope decodes a relay envelope JSON object into a transport.Envelope.
func rawEnvelope(r json.RawMessage) *transport.Envelope {
var e struct {
TCE string `json:"tce"`
Signature string `json:"signature"`
ObjectID string `json:"object_id"`
}
_ = json.Unmarshal(r, &e)
tceB, err1 := base64.StdEncoding.DecodeString(e.TCE)
sigB, err2 := base64.StdEncoding.DecodeString(e.Signature)
if err1 != nil || err2 != nil {
return nil
}
return &transport.Envelope{TCE: tceB, Signature: sigB, ObjectID: e.ObjectID}
}
// fetchEnvelopes GETs a relay list endpoint and returns its envelopes.
func fetchEnvelopes(u, token string) []*transport.Envelope {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
log.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
log.Fatalf("%s: %s", resp.Status, string(body))
}
var wrap struct {
Claims []json.RawMessage `json:"claims"`
Requests []json.RawMessage `json:"requests"`
Responses []json.RawMessage `json:"responses"`
Revocations []json.RawMessage `json:"revocations"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
log.Fatal(err)
}
out := make([]*transport.Envelope, 0)
for _, r := range wrap.Claims {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
for _, r := range wrap.Requests {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
for _, r := range wrap.Responses {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
for _, r := range wrap.Revocations {
if e := rawEnvelope(r); e != nil {
out = append(out, e)
}
}
return out
}
func cmdVerify(args []string) {
fs := flag.NewFlagSet("verify", flag.ExitOnError)
subject := fs.String("subject", "", "subject trust address")
predicate := fs.String("predicate", "", "claim key to evaluate")
approversStr := fs.String("approvers", "", "comma-separated approver addresses (optional)")
token := fs.String("token", "", "session token (scope read, or per-endpoint tokens)")
url := fs.String("url", "http://localhost:8080", "relay URL")
fs.Parse(args)
sub, err := address.Parse(*subject)
if err != nil {
log.Fatalf("subject: %v", err)
}
var approvers []address.Address
for _, a := range strings.Split(*approversStr, ",") {
a = strings.TrimSpace(a)
if a == "" {
continue
}
ad, err := address.Parse(a)
if err != nil {
log.Fatalf("approver %q: %v", a, err)
}
approvers = append(approvers, ad)
}
g := verify.NewGraph()
// Claims about the subject.
for _, e := range fetchEnvelopes(*url+"/v1/claims?subject="+*subject, *token) {
if err := g.Add(e); err != nil {
log.Printf("skip claim: %v", err)
}
}
// Revocations for each claim we saw.
for _, e := range fetchEnvelopes(*url+"/v1/claims?subject="+*subject, *token) {
id := e.ContentID()
for _, r := range fetchEnvelopes(*url+"/v1/revocations?claim="+id, *token) {
if err := g.Add(r); err != nil {
log.Printf("skip revocation: %v", err)
}
}
}
// Approvals, if required: requests to each approver and their responses.
for _, ad := range approvers {
for _, req := range fetchEnvelopes(*url+"/v1/requests?recipient="+ad.String(), *token) {
if err := g.Add(req); err != nil {
log.Printf("skip request: %v", err)
}
for _, resp := range fetchEnvelopes(*url+"/v1/responses?request="+req.ContentID(), *token) {
if err := g.Add(resp); err != nil {
log.Printf("skip response: %v", err)
}
}
}
}
res := g.Evaluate(verify.Policy{
Subject: sub,
Predicate: *predicate,
Approvers: approvers,
Now: uint64(time.Now().Unix()),
})
if res.Trusted {
fmt.Println("TRUSTED")
} else {
fmt.Println("NOT TRUSTED")
}
if res.Issuer.String() != "" {
fmt.Printf("issuer %s\n", res.Issuer)
}
if res.ApprovedBy.String() != "" {
fmt.Printf("approved_by %s\n", res.ApprovedBy)
}
if res.Revoked {
fmt.Println("revoked: yes")
}
if res.Reason != "" {
fmt.Printf("reason %s\n", res.Reason)
}
}

54
cmd/server/main.go Normal file
View file

@ -0,0 +1,54 @@
// Command server runs the trust relay over HTTP using the JSON transport.
//
// It holds no signing key: it stores and serves signed TCE objects and brokers
// authentication, but never forges (INV-1) and never decides authorization
// (INV-5).
package main
import (
"context"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/server"
)
func main() {
addr := flag.String("addr", ":8080", "listen address")
audience := flag.String("audience", "trust.n1ko.dev", "server audience bound into auth assertions")
data := flag.String("data", "", "directory to persist objects (empty = in-memory)")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
srv := server.New(*audience, *data)
h := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("trust relay listening", "addr", *addr, "audience", *audience)
if err := h.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("listen", "err", err)
os.Exit(1)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := h.Shutdown(ctx); err != nil {
logger.Error("shutdown", "err", err)
}
logger.Info("stopped")
}

82
docs/ADDRESS.md Normal file
View file

@ -0,0 +1,82 @@
# Trust address format
A trust address is the textual form of an Ed25519 public key. It is the only
identifier the protocol has for an identity.
```
trust1qqak5faue6m2gttz5w5dq2n0p4ek2vs4wuw7ysax8tqy3gvtt8dzj0yfahr
\___/\__________________________________________________/\_____/
hrp payload (53 chars) checksum
```
## Encoding
```
hrp = "trust"
payload = version(1 byte) || ed25519 public key(32 bytes)
address = bech32m(hrp, convertbits(payload, 8 -> 5, pad = true))
```
- **Version** is currently `0x00`, denoting a raw 32-byte Ed25519 public key.
Because bech32 regroups the payload into 5-bit units, a leading zero byte
renders as `q`, which is why every current address reads as `trust1q...`.
- **Length** is exactly 65 characters for version 0.
- **Case** is lowercase only. bech32 permits an all-uppercase form, but two
spellings of one address would violate INV-8, so uppercase input is refused.
## Why bech32m rather than bech32
bech32m (BIP-350) differs from the original bech32 (BIP-173) only in the
checksum constant. The original has a known weakness when the final data
character can vary in a length-extending way, which is why BIP-350 exists.
A trust payload is preceded by a version byte that is intended to change over
time, so bech32m is the correct choice.
Decoding enforces this exactly. The bech32 constant is recognised and
**rejected** with a distinct error, so a downgrade to the weaker checksum or a
confusion with a foreign address family is not accepted silently.
## Validation performed on decode
Parsing an address is strict, and every rule removes either an alternative
spelling or an unusable key:
| Check | Rejected because |
|---|---|
| non-empty, length <= 65 | bounds work done on untrusted input |
| lowercase only | two spellings of one address (INV-8) |
| bech32m checksum verifies | corruption must never decode to another identity |
| checksum constant is bech32m | downgrade or foreign address family |
| hrp is `trust` | address belongs to another protocol |
| payload is exactly 33 bytes | malformed |
| version is `0x00` | unknown protocol version |
| 5 to 8 bit regrouping leaves no non-zero padding | alternative spelling |
| key is a canonical curve point | non-canonical encodings alias one point |
| key is not of small order | universal signature forgery, see below |
## Public key validation
The last two rows are a cryptographic requirement, not a formatting one.
`crypto/ed25519.Verify` performs no checks on the public key, as RFC 8032
specifies. When the attacker chooses the key this is exploitable: the order-1
point (`0100...00`) has a signature that verifies against **every** message.
Anyone who registered such a key would own an identity whose signatures are
valid on any claim or approval that anyone cares to construct.
Every key is therefore checked to be a canonically encoded point that is not
annihilated by multiplication by the cofactor. Honest keys always pass; the
degenerate ones can never become an address, and therefore can never become an
identity.
## Properties relied on elsewhere
- **Bijective.** Every valid key has exactly one address; every valid address
yields exactly one key. Verified by fuzzing in both directions.
- **Self-validating.** A non-zero `Address` value has already passed every
check above, so later code never has to re-validate.
- **Corruption-detecting.** Every single-character substitution and every
transposition in the data part is rejected. A mistyped address fails to
parse rather than resolving to a different identity.
- **Fail-closed zero value.** The zero `Address` denotes no identity, does not
compare equal to itself, and stringifies to the empty string.

95
docs/API.md Normal file
View file

@ -0,0 +1,95 @@
# HTTP API
Base path: `/v1`. Transport is JSON. Requests and responses are
`transport.Envelope` objects (see `docs/PROTOCOL.md`).
## Authentication
Read endpoints require a session token. Obtain one with the challenge/assert
handshake (AuthAssertion bound to the server's `audience`):
```
POST /v1/auth/challenge -> { "challenge": "<hex>" }
POST /v1/auth/assert (envelope) -> { "session_token", "identity", "scope" }
```
Send the token as `Authorization: Bearer <token>` or `?token=<token>`. A session
is valid for 30 minutes; each challenge is single-use and expires after 5
minutes. Scopes: `read:claims`, `read:requests`, `read:responses`,
`read:revocations`, plus the generic `read` / `*`.
Rate limit: `POST /v1/auth/challenge` is capped at **30/min per IP** (`429`).
## Endpoints
| Method | Path | Auth | Query / body | Purpose |
|--------|--------------------------|-------------|-----------------------------|------------------------------------------|
| POST | `/v1/objects` | none | `Envelope` body | Store a signed object (claim/request/...) |
| GET | `/v1/objects/{id}` | none | — | Fetch one object by content id |
| GET | `/v1/claims` | session | `subject`, `limit`,`offset` | Claims about a subject |
| GET | `/v1/requests` | session | `recipient`,`limit`,`offset`| Approval requests to a recipient |
| GET | `/v1/responses` | session | `request`,`limit`,`offset` | Responses to a request |
| GET | `/v1/revocations` | session | `claim`,`limit`,`offset` | Revocations targeting a claim |
| GET | `/v1/config` | none | — | `{ "audience": "..." }` |
| GET | `/v1/metrics` | none | — | Prometheus-style metrics |
| GET | `/v1/healthz` | none | — | Liveness (`200`) |
| GET | `/v1/readyz` | none | — | Readiness (`200` / `503`) |
## Storing objects (`POST /v1/objects`)
- The relay does **not** verify the signature (see `docs/TRUST-MODEL.md`);
verification is the verifier's job. Any well-formed envelope is stored.
- **Idempotent**: replaying the same object returns the same `object_id` with
`200` and creates no duplicate.
- **Body cap**: the request body is limited to `MaxClaimTCE*2 + 1024` bytes
(~9 KiB). Larger bodies get `413`.
- **Per-subject quota**: a subject may hold at most **1000** claims. The 1001st
distinct claim returns `422` (`subject quota exceeded`).
- **One response per request**: a second `ApprovalResponse` for an already
answered request returns `422` (`request already answered`).
- **Rate limit**: `60/min` per IP (`429`).
Request / response shape:
```
PUT { "tce": "<base64>", "signature": "<base64>" }
200 { "object_id": "<content-id>" }
422 { "error": "server: subject ... quota exceeded" }
413 { "error": "payload too large" }
429 { "error": "rate limited" }
```
## Listing endpoints (claims/requests/responses/revocations)
All require a session with the matching `read:*` scope and return a JSON object
with a single list key (`claims`, `requests`, `responses`, `revocations`). Each
entry is a raw `Envelope` (`{ "tce": "<base64>", "signature": "<base64>" }`);
decode the `TCE` to read the issuer/subject/fields.
- `subject` / `recipient` / `request` / `claim` is required; omitting it is
`400`.
- Pagination: `limit` (max items, `0` = no cap) and `offset` (skip) query
params. Example: `?subject=trust1def&limit=10&offset=20`.
- Missing/invalid scope is `403`; missing token is `401`.
## Health & metrics
- `GET /v1/healthz``200 { "status": "ok" }`.
- `GET /v1/readyz``200 { "status": "ready" }`, or `503` if not ready.
- `GET /v1/metrics``text/plain` Prometheus exposition with at least:
`trust_objects_stored`, `trust_objects_rejected`, `trust_challenges_issued`,
`trust_assertions_ok`, `trust_assertions_failed`, `trust_sessions_active`.
## Status code summary
| Code | Meaning |
|------|-------------------------------------------|
| 200 | OK / stored / fetched |
| 400 | Malformed request (bad envelope, missing param) |
| 401 | Missing/invalid auth token or assertion |
| 403 | Token present but insufficient scope |
| 404 | Unknown object id |
| 413 | Request body exceeds the size cap |
| 422 | Well-formed but rejected (quota / already answered) |
| 429 | Rate limit exceeded |
| 503 | Server not ready |

View file

@ -0,0 +1,128 @@
# Implementation notes
Non-negotiable properties of the Go implementation of TCE and the protocol
objects. These restate, in implementation terms, what
[PROTOCOL.md](PROTOCOL.md) requires. Where this file and PROTOCOL.md appear to
disagree, PROTOCOL.md wins and the disagreement is a bug to be reported, not
resolved by changing the specification.
Each property is followed by how it is enforced in code, because a property
that is only written down is a property that will eventually be broken.
---
### 1. TCE is the signed message. SHA-256(TCE) is only the content ID.
The Ed25519 signature is computed over the canonical TCE bytes. The object ID
is a SHA-256 of those same bytes, used to name and reference the object. It is
never the signing input.
*Enforcement.* `Sign` and `Verify` accept TCE bytes. No function in the tree
signs or verifies a hash. `ObjectID` returns a distinct type, `tce.ID`, that
has no method accepting a signature, so a hash cannot be passed where a
message is expected.
### 2. Signature verification must use the exact TCE bytes.
The bytes that arrived are the bytes that are verified. An object is decoded
*from* the verified bytes; a decoded object is never re-encoded in order to
verify it.
*Enforcement.* The decoded object type retains the original byte slice, and
`Verify` uses that slice rather than re-encoding. `TestVerifyUsesReceivedBytes`
constructs a byte string that decodes successfully but is not what the encoder
would emit, and asserts that verification uses the received form. Because the
decoder is strict (property 5), such a byte string must in fact be rejected
outright; the test asserts rejection rather than silent re-encoding.
### 3. `request_hash` must equal SHA-256 of the exact canonical ApprovalRequest TCE.
A response commits to a full request, not to a label the sender chose.
*Enforcement.* There is no exported function that verifies a response on its
own. `VerifyResponse(req *SignedRequest, resp *SignedResponse)` requires both,
and compares `SHA-256(req.TCE)` with `resp.RequestHash` in constant time
before anything else. Omitting the request is a compile error, not a runtime
oversight.
### 4. AuthAssertion audience binding must be mandatory and exact.
An assertion produced for one server must never authenticate a connection to
another.
*Enforcement.* `VerifyAuthAssertion` takes the expected audience as a required
parameter and compares it with `subtle.ConstantTimeCompare`. There is no
default, no empty-means-any case, and no substring or suffix matching. An
empty expected audience is an error.
### 5. Malformed input is rejected, never repaired.
Unknown object tags, unknown object versions, unknown or reserved value tags,
non-minimal uvarints, truncated fields, trailing bytes, duplicate map keys,
unsorted map keys, invalid UTF-8, control characters, non-canonical numbers,
out-of-range timestamps and over-long fields are all errors.
The decoder never skips a field it does not understand. Skipping would mean
two implementations compute different meanings for the same signed bytes while
both see a valid signature.
*Enforcement.* Every case in the §12.3 table of PROTOCOL.md has a test. The
frozen `rejects` vectors are executed as a table test.
### 6. Revoked and missing claims are distinct states.
`Revoked` means a signed revocation by the claim's issuer exists and has been
verified. `Missing` means nothing was returned, which may be because the claim
never existed, expired, was withheld by a hostile relay, or was lost.
Absence is never denial. The protocol layer has no function that converts
"not found" into a negative answer, because a relay can withhold anything and
a consumer that treats silence as denial can be manipulated by censorship.
*Enforcement.* Claim status is a three-valued type: `StatusActive`,
`StatusRevoked`, `StatusExpired`. There is no `StatusDenied`, and no API
returns a boolean verdict for a claim. Whether an absent claim matters is a
decision for the consuming application (INV-5).
### 7. Aliases, transport metadata, server IDs, receipt timestamps and
signatures remain outside TCE.
*Enforcement.* The encoder builds each object from an explicit field list in
the order given by PROTOCOL.md §8. The alias appears only in
`IdentityRegistration`. No encoder function accepts a server-assigned
identifier, a receipt time or a signature. `TestNoAliasInSignedBytes` searches
the canonical bytes of a claim and an approval for an alias string and
requires it to be absent.
### 8. No JSON canonicalization, and no second signing representation.
There is exactly one signing format. JSON is a transport and display syntax
only.
*Enforcement.* The protocol package does not import `encoding/json`. JSON
handling lives in a separate wire package that can produce and parse the
transport envelope but cannot sign or verify. An import-graph test enforces
the separation, so a future contributor cannot add a JSON-based signing path
without the build failing.
---
## Additional implementation rules adopted for safety
**Bounded allocation.** A length prefix is checked against the remaining input
and the field's maximum before any allocation. A hostile 10-byte varint cannot
cause a large allocation.
**No mutation of caller data.** Decoded objects hold copies of the byte slices
they expose, so a caller cannot alter an object after it has been verified.
**Constant-time comparison** for hashes, nonces, audiences and signatures,
using `crypto/subtle`. These comparisons are not obviously timing-sensitive,
but the cost is negligible and the analysis needed to prove any individual
case safe is not worth repeating.
**Errors carry no attacker-controlled data.** Decode failures return a small
set of sentinel errors with a field name, never a fragment of the input.
**Determinism.** Encoding the same object twice yields identical bytes; this is
asserted by fuzzing rather than assumed.

138
docs/INVARIANTS.md Normal file
View file

@ -0,0 +1,138 @@
# Protocol invariants
These are normative. Every stage of the implementation is checked against
them, and several are enforced by tests that inspect the source tree so that a
violation breaks the build rather than merely contradicting a comment.
The single sentence the whole design reduces to:
> Cryptography establishes who said something.
> The application decides whether it trusts them and what their statement means.
## The invariants
**INV-1. A trust server compromise must never allow forging an identity,
claim, approval or authorization response.**
The server holds no signing key. Signing capability lives exclusively in
`internal/identity/signer`, which server packages do not import. An attacker
with full database and process control can read public data, delete it, delay
it, censor it and deny service, but cannot produce a signature.
*Enforced by* `TestNoSigningOutsideSigner`, `TestProtocolDoesNotImportSigner`.
**INV-2. trust.n1ko.dev is never an issuer of claims or approvals.**
There is no server identity, no root key and no certificate authority. The
server never appears as the `issuer` of a claim or the `sender` of a request.
The only random material it produces is authentication challenge nonces, which
are not protocol objects and carry no signature.
**INV-3. Database identifiers are never security identifiers.**
Security identity is always cryptographic: an Ed25519 public key, or a
content-addressed hash of canonical object bytes. No autoincrement column, row
id or server-assigned handle ever appears in a signed object or in an
authorization-relevant comparison.
**INV-4. An ApprovalResponse is valid only when its `request_hash` matches the
exact canonical ApprovalRequest bytes.**
The response commits to the full request, not to a sender-chosen label.
Verification takes both objects, so there is no API that can check a response
in isolation and no way to reuse a signed decision under a different request.
**INV-5. Applications must perform their own authorization decisions.**
The protocol authenticates statements and approvals. It never answers "is this
allowed". No API returns a permission verdict, and no package contains a
notion of role, capability or permission.
*Enforced by* `TestNoApplicationSemantics`.
**INV-6. No implicit transitive trust.**
If A trusts B, that says nothing about identities B trusts. The server never
traverses a trust graph, never joins claims across issuers and never derives a
statement that no one signed.
**INV-7. Aliases are never security-sensitive.**
An alias is a self-asserted display label. It is not unique, not verified, and
must not participate in signature verification or authorization. Aliases are
excluded from the canonical encoding of claims and approvals. User interfaces
must never display an alias without its address.
*Enforced by* `TestAliasNeverAffectsVerification`, `TestDisplayAlwaysShowsAddress`.
**INV-8. Every security-sensitive object has a precisely defined canonical
representation, specified before it is implemented.**
Signatures are computed over canonical bytes, never over JSON. Each object has
exactly one valid encoding; any input with two possible spellings is either
normalised to one or rejected.
**INV-9. Protocol packages must not import application-specific packages.**
The protocol layer depends only on the standard library and vetted
cryptographic primitives. Storage, transport and application semantics stay
outside it.
*Enforced by* `TestProtocolLayerImports`.
**INV-10. Property and fuzz tests for all parsers, canonicalization, address
decoding, TCE decoding and signature verification.**
Every parser that touches untrusted input has a fuzz target asserting
totality (no panic, no hang, no unbounded allocation) and the specific
correctness properties of that parser.
## The canonical pipeline
```
canonical object
|
v
TCE deterministic, length-prefixed binary encoding
|
v
SHA-256
|
v
object ID content address; identifies and references the object
|
v
Ed25519 signature computed over the TCE bytes, not over the ID
```
Consequences that follow from this pipeline and are treated as binding:
- The signature covers the TCE bytes. The hash is used for identification and
reference only, never as the signing input.
- The object ID is a content address, so INV-3 holds by construction and the
`request_hash` check in INV-4 is a byte comparison.
- The TCE encoding of an object excludes its own signature field.
- Canonicalization is total and rejecting: duplicate map keys, invalid UTF-8,
unknown type tags and non-normalizable numbers are all refused rather than
repaired.
- `Decode(Encode(x)) == x` and `Encode(Decode(b)) == b` are both fuzz
properties. The second rules out malleability, where two distinct byte
strings decode to the same object.
## Additional rule adopted during Stage 1
**Public keys must be canonical points of the prime-order subgroup.**
`crypto/ed25519.Verify` follows RFC 8032 and performs no checks on the public
key. That is not sufficient when the attacker chooses the key. The order-1
point admits a single signature that verifies against every message, which
would give its holder an identity whose signature is valid on any claim or
approval in existence: a direct break of INV-1.
Every public key entering the system is therefore validated as a canonically
encoded, non-small-order curve point before it can become an address. The
check happens once, at address construction, so that any `Address` or
`Identity` value elsewhere in the program has already passed it.
*Enforced by* `TestRejectsSmallOrderKeys`, `TestDegenerateKeyWouldForgeSignatures`,
`TestParseRejectsEmbeddedSmallOrderKey`.

797
docs/PROTOCOL.md Normal file
View file

@ -0,0 +1,797 @@
# 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.

99
docs/TRUST-MODEL.md Normal file
View file

@ -0,0 +1,99 @@
# Trust model
This document explains *who* is trusted to say *what*, and why the relay itself
never makes a trust decision. Read this before assuming a missing signature
check is a vulnerability.
## Roles
| Role | What it is |
|------------|-------------------------------------------------------------------------|
| Issuer | Holds a signing key; makes a signed statement (a claim, a response). |
| Subject | The address a claim is *about*. |
| Approver | An address trusted to allow/deny an `ApprovalRequest`. |
| Relay | The HTTP server (`internal/server`). Stores signed objects. No keys. |
| Verifier | The consumer running `internal/verify`. Checks signatures, decides trust. |
## Objects are signed by their issuer
Every object on the wire is a `transport.Envelope{TCE, Signature}`. The `TCE`
is a self-describing, content-addressed blob; the `Signature` is over the exact
`TCE` bytes and is verified against the **public key embedded inside the TCE**
(the issuer's key).
A claim therefore reads as:
```
issuer = <key that signed it>
subject = <address the claim is about>
claims = { predicate: value }
```
The signature binds the statement to the issuer's key. There is no way to emit a
record that *appears* to come from an issuer without holding that issuer's key.
## Worked example
> `trust1abc` says: `trust1def` has `minecraft.op: true`.
1. `trust1abc` builds a `Claim{Issuer: trust1abc, Subject: trust1def,
Claims: {"minecraft.op": true}}`, encodes it to `TCE`, signs it with
`trust1abc`'s key.
2. `PUT /v1/objects` stores the envelope. The relay does **not** verify the
signature — it only checks the envelope is well-formed and content-addressed.
3. Later, a consumer queries `GET /v1/claims?subject=trust1def` and receives a
list of **envelopes** (`{ "tce": "<base64>", "signature": "<base64>" }`).
The consumer decodes the `TCE` to read `issuer` / `subject` / `claims`.
4. The consumer runs `verify.Graph.Add(env)`, which calls `env.Verify()` and
confirms the signature matches `trust1abc`'s key — i.e. `trust1abc` really
said that.
An attacker cannot forge "from `trust1abc`": any record they produce carries
**their own** key as `issuer`, so it shows up attributed to the attacker and is
ignored by anyone who does not trust that key.
## Why the relay does not verify signatures
The relay implements INV-5 ("who said what"), not "should I believe it". It is a
content-addressed bulletin board:
- It accepts any well-formed envelope (`PUT` returns `200` even for a bad
signature). Storing unverified data is correct and intentional.
- All cryptographic trust decisions live in `internal/verify`, which never talks
to the network and never holds a key.
Consequence: **signature verification is the verifier's job, not the server's.**
A `PUT` with a tampered signature is stored; `verify.Graph.Add` rejects it.
## Who do you believe? (issuer anchoring)
Because any key can sign a claim about any subject, the *verifier* must decide
which issuers it trusts. This is done **out of band**, by the consumer:
- `verify.Result.Issuer` is always returned (verify.go). The consumer compares
it to the set of issuers it trusts, e.g. `result.Issuer == trust1abc`.
- For approvals, trust is anchored explicitly: `Policy.Approvers` lists the
addresses whose `Allow` responses count, and `Policy.Threshold` is the k-of-n.
There is intentionally **no** `Policy.Issuer` filter: the model is "feed the
verifier only the objects you retrieved, then trust based on `Result.Issuer`".
If you need issuer restriction inside evaluation, add an `Issuer`/`TrustedIssuers`
field to `Policy` — but that is a policy choice, not a relay concern.
## Approval flow
1. Issuer sends `ApprovalRequest{Recipient: approver, Action: predicate, ...}`.
2. Approver sends `ApprovalResponse{RequestHash, Decision: Allow, ...}` signed by
the approver. The response is bound to the request and verified together via
`protocol.VerifyApprovalResponse`.
3. The relay enforces **at most one response per request** (`422` on a second).
4. `verify` collects valid `Allow` responses from `Policy.Approvers` and checks
the count against `Policy.Threshold`.
## Revocation
- A claim is revoked by a `Revocation` signed by the claim's issuer
(`protocol.VerifyRevocationOf`).
- An approval is withdrawn by a `Revocation` targeting the response's object id,
signed by the responder (the approver revoking their own decision).

8
go.mod Normal file
View file

@ -0,0 +1,8 @@
module git.n1ko.dev/Niko/niko_trust
go 1.25
require (
filippo.io/edwards25519 v1.1.0
github.com/btcsuite/btcd/btcutil v1.1.6
)

107
go.sum Normal file
View file

@ -0,0 +1,107 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c=
github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

286
internal/address/address.go Normal file
View file

@ -0,0 +1,286 @@
// Package address implements trust addresses: a bech32m encoding of an
// Ed25519 public key together with a protocol version byte.
//
// Normative invariants enforced here:
//
// - INV-3: an address is derived exclusively from cryptographic material.
// It is never a database identifier and carries no server-assigned state.
// - INV-7: an address never contains an alias or any other human-chosen
// label. Aliases are not security-sensitive and must not round-trip
// through this package.
// - INV-8: the mapping public key <-> address is total and canonical. Every
// valid public key has exactly one valid address encoding, and every valid
// address decodes to exactly one public key.
// - INV-9: this package imports only the standard library and a vetted
// bech32 implementation. It knows nothing about claims, approvals,
// storage, transport or any application semantics.
//
// Encoding:
//
// hrp = "trust"
// payload = version(1 byte) || ed25519 public key(32 bytes)
// address = bech32m(hrp, convertbits(payload, 8 -> 5, pad=true))
//
// bech32m (BIP-350) is used rather than the original bech32 (BIP-173). The
// original checksum has a known weakness when the final data character can
// vary in a length-extending way; BIP-350 replaces the checksum constant to
// fix it. Because a trust payload is a fixed-length blob preceded by a version
// byte that we intend to extend over time, the bech32m constant is the correct
// choice. Decoding rejects the bech32 (Version0) constant outright, so a
// checksum-variant downgrade is not accepted.
package address
import (
"crypto/ed25519"
"errors"
"fmt"
"strings"
"github.com/btcsuite/btcd/btcutil/bech32"
)
// HRP is the human-readable part of every trust address.
const HRP = "trust"
// Version0 is the only protocol version currently defined. It denotes a
// payload consisting of a raw 32-byte Ed25519 public key.
//
// The version byte is the first byte of the 8-bit payload. Because bech32
// regroups the payload into 5-bit units, a leading zero byte renders as the
// character 'q', which is why version 0 addresses read as "trust1q...".
const Version0 byte = 0x00
// PubKeySize is the size of an Ed25519 public key in bytes.
const PubKeySize = ed25519.PublicKeySize
// payloadSize is the size of the decoded 8-bit payload: version || pubkey.
const payloadSize = 1 + PubKeySize
// EncodedLen is the exact character length of a version 0 trust address.
//
// len("trust") + len("1") + ceil(33*8/5) + len(checksum) = 5 + 1 + 53 + 6
const EncodedLen = len(HRP) + 1 + 53 + 6
// maxEncodedLen bounds the input accepted by Decode. It is deliberately a
// small constant rather than the bech32 limit of 90: no valid trust address is
// longer than EncodedLen, and refusing longer input early keeps the parser
// cheap to call on untrusted data.
const maxEncodedLen = EncodedLen
var (
// ErrEmpty is returned when decoding an empty string.
ErrEmpty = errors.New("address: empty")
// ErrTooLong is returned when the input cannot possibly be an address.
ErrTooLong = errors.New("address: too long")
// ErrNotLowercase is returned for input containing uppercase characters.
// bech32 permits an all-uppercase form, but permitting two spellings of
// one address would violate INV-8, so only lowercase is accepted.
ErrNotLowercase = errors.New("address: must be lowercase")
// ErrChecksum is returned when the bech32m checksum does not verify.
ErrChecksum = errors.New("address: invalid checksum")
// ErrNotBech32m is returned when the string carries a valid checksum, but
// computed with the original bech32 constant instead of bech32m. This is a
// downgrade attempt or a foreign address type and is always rejected.
ErrNotBech32m = errors.New("address: not bech32m")
// ErrWrongHRP is returned when the human-readable part is not "trust".
ErrWrongHRP = errors.New("address: wrong human-readable part")
// ErrPayloadSize is returned when the decoded payload is not exactly
// version || 32-byte public key.
ErrPayloadSize = errors.New("address: wrong payload size")
// ErrVersion is returned for an unknown protocol version byte.
ErrVersion = errors.New("address: unsupported version")
// ErrPadding is returned when the 5-bit to 8-bit regrouping leaves
// non-zero padding bits. Such a string is a second spelling of an address
// that already has a canonical form, so accepting it would violate INV-8.
ErrPadding = errors.New("address: non-canonical padding")
)
// Address is a validated, canonical trust address.
//
// The zero Address is invalid. Values of this type are only produced by
// Parse, FromPubKey or their variants, so a non-zero Address is always
// well-formed: its string form is canonical and its public key has already
// passed curve validation.
type Address struct {
// s is the canonical lowercase bech32m string.
s string
// key is the decoded public key. Stored as an array rather than a slice so
// that Address remains comparable and cannot be mutated through an alias
// of the caller's backing array.
key [PubKeySize]byte
// version is the protocol version byte.
version byte
}
// FromPubKey encodes an Ed25519 public key as a version 0 trust address.
//
// The key is validated as a curve point before encoding; see [ValidatePubKey].
// This means it is not possible to construct an Address for a small-order or
// non-canonically encoded key, which is what prevents such a key from ever
// entering the protocol as an identity.
func FromPubKey(pub ed25519.PublicKey) (Address, error) {
if err := ValidatePubKey(pub); err != nil {
return Address{}, err
}
return fromValidatedKey(Version0, pub)
}
// fromValidatedKey encodes a key that has already passed validation.
func fromValidatedKey(version byte, pub []byte) (Address, error) {
payload := make([]byte, 0, payloadSize)
payload = append(payload, version)
payload = append(payload, pub...)
conv, err := bech32.ConvertBits(payload, 8, 5, true)
if err != nil {
return Address{}, fmt.Errorf("address: convert bits: %w", err)
}
s, err := bech32.EncodeM(HRP, conv)
if err != nil {
return Address{}, fmt.Errorf("address: encode: %w", err)
}
a := Address{s: s, version: version}
copy(a.key[:], pub)
return a, nil
}
// Parse decodes and validates a trust address.
//
// Parse is strict by design. It rejects uppercase input, the bech32 checksum
// constant, unknown versions, wrong payload lengths, non-zero padding bits and
// public keys that are not valid curve points of the prime-order subgroup.
// Every rejection removes an alternative spelling or an unusable key, which is
// what makes the address space canonical (INV-8).
func Parse(s string) (Address, error) {
switch {
case s == "":
return Address{}, ErrEmpty
case len(s) > maxEncodedLen:
return Address{}, ErrTooLong
}
// Reject uppercase before handing the string to the bech32 decoder, which
// would otherwise normalise it and accept a second spelling.
if strings.ToLower(s) != s {
return Address{}, ErrNotLowercase
}
hrp, data, version, err := bech32.DecodeNoLimitWithVersion(s)
if err != nil {
// Collapse the library's error taxonomy: distinguishing "bad
// character" from "bad checksum" tells an attacker nothing useful and
// invites callers to branch on parse failure modes.
return Address{}, fmt.Errorf("%w: %v", ErrChecksum, err)
}
// Enforce bech32m exactly. DecodeNoLimitWithVersion accepts either
// checksum constant and reports which one matched; anything other than
// VersionM is a different address family or a downgrade attempt.
if version != bech32.VersionM {
return Address{}, ErrNotBech32m
}
if hrp != HRP {
return Address{}, ErrWrongHRP
}
payload, err := convertFromBech32(data)
if err != nil {
return Address{}, err
}
if len(payload) != payloadSize {
return Address{}, ErrPayloadSize
}
if payload[0] != Version0 {
return Address{}, ErrVersion
}
pub := payload[1:]
if err := ValidatePubKey(pub); err != nil {
return Address{}, err
}
a := Address{s: s, version: payload[0]}
copy(a.key[:], pub)
return a, nil
}
// convertFromBech32 regroups 5-bit data into 8-bit bytes, rejecting any
// encoding that carries non-zero padding bits or a trailing incomplete group
// that a canonical encoder would never emit.
func convertFromBech32(data []byte) ([]byte, error) {
// pad=false makes ConvertBits reject leftover bits that are non-zero or
// wider than 4, which is exactly the canonical-form requirement.
out, err := bech32.ConvertBits(data, 5, 8, false)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrPadding, err)
}
return out, nil
}
// MustParse is Parse for constants and test fixtures. It panics on error and
// must never be used on untrusted input.
func MustParse(s string) Address {
a, err := Parse(s)
if err != nil {
panic("address: MustParse: " + err.Error())
}
return a
}
// String returns the canonical bech32m encoding.
func (a Address) String() string { return a.s }
// IsZero reports whether a is the unset zero value.
func (a Address) IsZero() bool { return a.s == "" }
// Version returns the protocol version byte.
func (a Address) Version() byte { return a.version }
// PubKey returns a copy of the Ed25519 public key. A copy is returned so that
// a caller cannot mutate the key held inside a validated Address.
func (a Address) PubKey() ed25519.PublicKey {
out := make(ed25519.PublicKey, PubKeySize)
copy(out, a.key[:])
return out
}
// KeyBytes returns the public key as a fixed-size array.
func (a Address) KeyBytes() [PubKeySize]byte { return a.key }
// Equal reports whether two addresses denote the same public key.
//
// Comparison is on the key rather than the string so that the result stays
// correct if a future version byte changes the textual form of the same key.
func (a Address) Equal(b Address) bool {
return a.key == b.key && a.version == b.version && !a.IsZero() && !b.IsZero()
}
// MarshalText implements encoding.TextMarshaler.
func (a Address) MarshalText() ([]byte, error) {
if a.IsZero() {
return nil, ErrEmpty
}
return []byte(a.s), nil
}
// UnmarshalText implements encoding.TextUnmarshaler. Decoding runs the full
// validation path, so an Address obtained from JSON is as trustworthy as one
// obtained from Parse.
func (a *Address) UnmarshalText(b []byte) error {
parsed, err := Parse(string(b))
if err != nil {
return err
}
*a = parsed
return nil
}

View file

@ -0,0 +1,388 @@
package address_test
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"math/rand"
"strings"
"testing"
"github.com/btcsuite/btcd/btcutil/bech32"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
// deterministicKey returns a reproducible valid Ed25519 public key.
func deterministicKey(t *testing.T, seedByte byte) ed25519.PublicKey {
t.Helper()
seed := make([]byte, ed25519.SeedSize)
for i := range seed {
seed[i] = seedByte
}
return ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
}
func TestRoundTrip(t *testing.T) {
for i := 0; i < 256; i++ {
pub := deterministicKey(t, byte(i))
a, err := address.FromPubKey(pub)
if err != nil {
t.Fatalf("FromPubKey: %v", err)
}
back, err := address.Parse(a.String())
if err != nil {
t.Fatalf("Parse(%q): %v", a.String(), err)
}
if !back.Equal(a) {
t.Fatalf("round trip changed address")
}
if !ed25519.PublicKey(back.PubKey()).Equal(pub) {
t.Fatalf("public key not recovered exactly")
}
}
}
func TestAddressShape(t *testing.T) {
pub := deterministicKey(t, 7)
a, err := address.FromPubKey(pub)
if err != nil {
t.Fatal(err)
}
s := a.String()
if !strings.HasPrefix(s, "trust1q") {
t.Errorf("address must start with trust1q, got %q", s)
}
if len(s) != address.EncodedLen {
t.Errorf("length = %d, want %d (%q)", len(s), address.EncodedLen, s)
}
if strings.ToLower(s) != s {
t.Errorf("address must be lowercase")
}
if a.Version() != address.Version0 {
t.Errorf("version = %d, want 0", a.Version())
}
t.Logf("sample address: %s", s)
}
// TestKnownVector freezes the encoding. If this test fails, the wire format
// changed and every previously issued address became invalid.
func TestKnownVector(t *testing.T) {
seed := make([]byte, 32) // all zero seed
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
const wantPub = "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29"
if got := hex.EncodeToString(pub); got != wantPub {
t.Fatalf("test vector public key drifted: %s", got)
}
a, err := address.FromPubKey(pub)
if err != nil {
t.Fatal(err)
}
// Verified against an independent BIP-350 reference implementation, not
// against this package's own output.
const want = "trust1qqak5faue6m2gttz5w5dq2n0p4ek2vs4wuw7ysax8tqy3gvtt8dzj0yfahr"
if a.String() != want {
t.Fatalf("encoding drifted:\n got %s\nwant %s", a.String(), want)
}
}
func TestParseRejectsInvalid(t *testing.T) {
valid, err := address.FromPubKey(deterministicKey(t, 3))
if err != nil {
t.Fatal(err)
}
v := valid.String()
tests := []struct {
name string
in string
want error
}{
{"empty", "", address.ErrEmpty},
{"uppercase", strings.ToUpper(v), address.ErrNotLowercase},
{"mixed case", strings.ToUpper(v[:6]) + v[6:], address.ErrNotLowercase},
{"too long", v + "qqqqqqqqqq", address.ErrTooLong},
{"no separator", strings.ReplaceAll(v, "1", "q"), address.ErrChecksum},
{"truncated", v[:len(v)-1], address.ErrChecksum},
{"extended", v + "q", address.ErrTooLong},
{"bare hrp", "trust1", address.ErrChecksum},
{"garbage", "not-an-address", address.ErrChecksum},
{"invalid char b", strings.Replace(v, "q", "b", 1), address.ErrChecksum},
{"space", "trust1 " + v[7:], address.ErrChecksum},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := address.Parse(tc.in)
if err == nil {
t.Fatalf("accepted invalid input %q", tc.in)
}
if tc.want != nil && !errorIs(err, tc.want) {
t.Fatalf("error = %v, want %v", err, tc.want)
}
})
}
}
func errorIs(err, target error) bool {
return err != nil && (err == target || strings.Contains(err.Error(), target.Error()))
}
// TestRejectsBech32NotBech32m is the checksum downgrade test. The same payload
// encoded with the original bech32 constant must be refused, otherwise two
// different strings would denote one identity and a caller could be tricked
// into accepting a foreign address family.
func TestRejectsBech32NotBech32m(t *testing.T) {
pub := deterministicKey(t, 11)
payload := append([]byte{address.Version0}, pub...)
conv, err := bech32.ConvertBits(payload, 8, 5, true)
if err != nil {
t.Fatal(err)
}
legacy, err := bech32.Encode(address.HRP, conv) // bech32, not bech32m
if err != nil {
t.Fatal(err)
}
modern, err := bech32.EncodeM(address.HRP, conv)
if err != nil {
t.Fatal(err)
}
if legacy == modern {
t.Fatal("test is meaningless: both constants produced the same string")
}
if _, err := address.Parse(legacy); !errorIs(err, address.ErrNotBech32m) {
t.Fatalf("bech32 (non-m) accepted or wrong error: %v", err)
}
if _, err := address.Parse(modern); err != nil {
t.Fatalf("bech32m rejected: %v", err)
}
}
func TestRejectsWrongHRP(t *testing.T) {
pub := deterministicKey(t, 13)
payload := append([]byte{address.Version0}, pub...)
conv, _ := bech32.ConvertBits(payload, 8, 5, true)
for _, hrp := range []string{"bc", "trus", "trustx", "tb", "trust2"} {
s, err := bech32.EncodeM(hrp, conv)
if err != nil {
t.Fatalf("encode %q: %v", hrp, err)
}
if _, err := address.Parse(s); err == nil {
t.Fatalf("accepted foreign hrp %q", hrp)
}
}
}
func TestRejectsUnknownVersion(t *testing.T) {
pub := deterministicKey(t, 17)
for _, ver := range []byte{1, 2, 0x7f, 0xff} {
payload := append([]byte{ver}, pub...)
conv, _ := bech32.ConvertBits(payload, 8, 5, true)
s, _ := bech32.EncodeM(address.HRP, conv)
if _, err := address.Parse(s); !errorIs(err, address.ErrVersion) {
t.Fatalf("version %d: err = %v, want ErrVersion", ver, err)
}
}
}
func TestRejectsWrongPayloadSize(t *testing.T) {
for _, n := range []int{0, 1, 16, 31, 33, 40} {
payload := make([]byte, n+1)
conv, _ := bech32.ConvertBits(payload, 8, 5, true)
s, err := bech32.EncodeM(address.HRP, conv)
if err != nil {
continue
}
if _, err := address.Parse(s); err == nil {
t.Fatalf("accepted payload of %d key bytes", n)
}
}
}
// TestChecksumDetectsSingleCharCorruption is the core integrity property of
// bech32: any single character substitution must invalidate the string. A
// corrupted address must never silently decode to a different valid identity,
// because that would send a claim or an approval to the wrong party.
func TestChecksumDetectsSingleCharCorruption(t *testing.T) {
const charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
valid, err := address.FromPubKey(deterministicKey(t, 23))
if err != nil {
t.Fatal(err)
}
v := valid.String()
sep := strings.LastIndexByte(v, '1')
mutations, accepted := 0, 0
for i := sep + 1; i < len(v); i++ {
for _, c := range charset {
if byte(c) == v[i] {
continue
}
mutated := v[:i] + string(c) + v[i+1:]
mutations++
got, err := address.Parse(mutated)
if err == nil {
accepted++
if got.Equal(valid) {
t.Fatalf("corruption produced the same identity")
}
t.Errorf("single-char corruption at %d accepted: %s", i, mutated)
}
}
}
if mutations == 0 {
t.Fatal("no mutations generated")
}
t.Logf("tested %d single-character corruptions, %d accepted", mutations, accepted)
}
// TestTwoCharSwapDetected covers transposition, the most common human
// transcription error.
func TestTwoCharSwapDetected(t *testing.T) {
valid, _ := address.FromPubKey(deterministicKey(t, 29))
v := valid.String()
sep := strings.LastIndexByte(v, '1')
swaps, accepted := 0, 0
for i := sep + 1; i < len(v)-1; i++ {
if v[i] == v[i+1] {
continue
}
m := v[:i] + string(v[i+1]) + string(v[i]) + v[i+2:]
swaps++
if _, err := address.Parse(m); err == nil {
accepted++
t.Errorf("transposition at %d accepted: %s", i, m)
}
}
t.Logf("tested %d transpositions, %d accepted", swaps, accepted)
}
func TestEqualAndZero(t *testing.T) {
a, _ := address.FromPubKey(deterministicKey(t, 31))
b, _ := address.FromPubKey(deterministicKey(t, 31))
c, _ := address.FromPubKey(deterministicKey(t, 32))
if !a.Equal(b) {
t.Error("same key must be equal")
}
if a.Equal(c) {
t.Error("different keys must not be equal")
}
var zero address.Address
if !zero.IsZero() {
t.Error("zero value must report IsZero")
}
if zero.Equal(zero) {
t.Error("zero address must not compare equal to itself; it denotes no identity")
}
if a.Equal(zero) || zero.Equal(a) {
t.Error("zero must not equal a real address")
}
if zero.String() != "" {
t.Error("zero address must stringify to empty")
}
}
// TestPubKeyIsCopied guards against a caller mutating the key inside a
// validated Address, which would let validated and actual key diverge.
func TestPubKeyIsCopied(t *testing.T) {
a, _ := address.FromPubKey(deterministicKey(t, 37))
k1 := a.PubKey()
for i := range k1 {
k1[i] = 0xff
}
k2 := a.PubKey()
for i := range k2 {
if k2[i] == 0xff {
t.Fatal("mutating a returned key changed the Address")
}
}
if _, err := address.Parse(a.String()); err != nil {
t.Fatalf("address became invalid after caller mutation: %v", err)
}
}
func TestTextMarshaling(t *testing.T) {
a, _ := address.FromPubKey(deterministicKey(t, 41))
b, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
if string(b) != `"`+a.String()+`"` {
t.Fatalf("json = %s", b)
}
var back address.Address
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
if !back.Equal(a) {
t.Fatal("json round trip failed")
}
// Unmarshaling must run full validation, not just store the string.
var bad address.Address
if err := json.Unmarshal([]byte(`"trust1qinvalid"`), &bad); err == nil {
t.Fatal("unmarshal accepted an invalid address")
}
var zero address.Address
if _, err := json.Marshal(zero); err == nil {
t.Fatal("marshaling the zero address must fail")
}
}
func TestMustParsePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("MustParse did not panic on invalid input")
}
}()
address.MustParse("trust1qnope")
}
// TestNoTwoStringsForOneKey asserts canonicality (INV-8) over random keys.
func TestNoTwoStringsForOneKey(t *testing.T) {
rng := rand.New(rand.NewSource(1))
seen := make(map[[32]byte]string)
for i := 0; i < 500; i++ {
seed := make([]byte, ed25519.SeedSize)
rng.Read(seed)
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
a, err := address.FromPubKey(pub)
if err != nil {
t.Fatal(err)
}
key := a.KeyBytes()
if prev, ok := seen[key]; ok && prev != a.String() {
t.Fatalf("one key produced two addresses: %s and %s", prev, a.String())
}
seen[key] = a.String()
// Encoding the same key twice must be byte-identical.
again, _ := address.FromPubKey(pub)
if again.String() != a.String() {
t.Fatal("encoding is not deterministic")
}
}
}
func BenchmarkParse(b *testing.B) {
seed := make([]byte, ed25519.SeedSize)
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
a, _ := address.FromPubKey(pub)
s := a.String()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := address.Parse(s); err != nil {
b.Fatal(err)
}
}
}

View file

@ -0,0 +1,203 @@
package address_test
import (
"crypto/ed25519"
"strings"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
func seedCorpus(f *testing.F) {
f.Helper()
seed := make([]byte, ed25519.SeedSize)
for i := 0; i < 4; i++ {
for j := range seed {
seed[j] = byte(i*7 + j)
}
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
a, err := address.FromPubKey(pub)
if err != nil {
f.Fatal(err)
}
f.Add(a.String())
}
for _, s := range []string{
"",
"trust",
"trust1",
"trust11",
"trust1qqqqqqqq",
"TRUST1QQQQQQQQ",
"trust1qqak5faue6m2gttz5w5dq2n0p4ek2vs4wuw7ysax8tqy3gvtt8dzj0yfahr",
"trust1qqak5faue6m2gttz5w5dq2n0p4ek2vs4wuw7ysax8tqy3gvtt8dzj0yfahR",
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
"trust1\x00\xff",
"trust1" + strings.Repeat("q", 200),
"1qqqqqq",
"trust1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq",
"\x00\x00\x00\x00\x00\x00\x00\x00",
} {
f.Add(s)
}
}
// FuzzParse asserts that Parse is total: for arbitrary input it returns
// either a fully valid Address or an error, and never panics, hangs or
// allocates unboundedly.
//
// Every Address it does return must satisfy the package's invariants, which is
// what makes "a non-zero Address is always well formed" a safe assumption for
// the rest of the program.
func FuzzParse(f *testing.F) {
seedCorpus(f)
f.Fuzz(func(t *testing.T, s string) {
a, err := address.Parse(s)
if err != nil {
if !a.IsZero() {
t.Fatalf("Parse returned an address alongside an error")
}
return
}
// Property 1: an accepted address is exactly the input. If Parse ever
// normalised its input, two strings would denote one identity and
// INV-8 would be violated.
if a.String() != s {
t.Fatalf("Parse normalised input: got %q, input %q", a.String(), s)
}
// Property 2: the key inside is always valid.
if err := address.ValidatePubKey(a.PubKey()); err != nil {
t.Fatalf("accepted address carries an invalid key: %v", err)
}
// Property 3: re-encoding the recovered key reproduces the address
// byte for byte. Encode and Decode are inverse on the accepted set.
re, err := address.FromPubKey(a.PubKey())
if err != nil {
t.Fatalf("could not re-encode a key recovered from a valid address: %v", err)
}
if re.String() != s {
t.Fatalf("encode(decode(x)) != x:\n got %q\nwant %q", re.String(), s)
}
// Property 4: parsing is idempotent and stable.
again, err := address.Parse(a.String())
if err != nil || !again.Equal(a) {
t.Fatalf("Parse is not idempotent: %v", err)
}
// Property 5: shape guarantees the rest of the code may rely on.
if len(s) != address.EncodedLen {
t.Fatalf("accepted address of length %d, want %d", len(s), address.EncodedLen)
}
if !strings.HasPrefix(s, address.HRP+"1") {
t.Fatalf("accepted address without the trust1 prefix")
}
if strings.ToLower(s) != s {
t.Fatalf("accepted a non-lowercase address")
}
if a.Version() != address.Version0 {
t.Fatalf("accepted unknown version %d", a.Version())
}
})
}
// FuzzRoundTrip asserts the property in the other direction: every valid key
// encodes to a string that parses back to the same key.
func FuzzRoundTrip(f *testing.F) {
f.Add(make([]byte, ed25519.SeedSize))
f.Add([]byte("0123456789abcdef0123456789abcdef"))
f.Fuzz(func(t *testing.T, seed []byte) {
if len(seed) != ed25519.SeedSize {
return
}
pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
a, err := address.FromPubKey(pub)
if err != nil {
// Only a degenerate key may be refused, and a key derived from a
// seed through the standard construction is never degenerate.
t.Fatalf("valid derived key rejected: %v", err)
}
back, err := address.Parse(a.String())
if err != nil {
t.Fatalf("own output rejected by Parse: %v", err)
}
if !ed25519.PublicKey(back.PubKey()).Equal(pub) {
t.Fatalf("key not preserved through the round trip")
}
if back.KeyBytes() != a.KeyBytes() {
t.Fatalf("KeyBytes differ after round trip")
}
})
}
// FuzzValidatePubKey asserts that key validation is total and agrees with
// itself: it never panics on arbitrary bytes, and a key it accepts is always
// canonical, meaning it survives an encode/decode cycle unchanged.
func FuzzValidatePubKey(f *testing.F) {
f.Add(make([]byte, 32))
f.Add(make([]byte, 31))
f.Add([]byte{})
pub, _, _ := ed25519.GenerateKey(nil)
f.Add([]byte(pub))
f.Fuzz(func(t *testing.T, key []byte) {
err := address.ValidatePubKey(key)
if err != nil {
return
}
if len(key) != ed25519.PublicKeySize {
t.Fatalf("accepted a key of length %d", len(key))
}
// An accepted key must be usable as an address and recoverable.
a, aerr := address.FromPubKey(key)
if aerr != nil {
t.Fatalf("ValidatePubKey accepted a key FromPubKey rejected: %v", aerr)
}
got := a.PubKey()
for i := range key {
if key[i] != got[i] {
t.Fatalf("key altered by the address round trip at byte %d", i)
}
}
// Validation must be deterministic.
if err2 := address.ValidatePubKey(key); err2 != nil {
t.Fatalf("validation is not deterministic: %v", err2)
}
})
}
// FuzzNoAliasingBetweenAddresses asserts the injectivity property that matters
// for delivery: two different accepted addresses never denote the same key.
// If they could, an approval request addressed to one identity could be
// answered by another.
func FuzzNoAliasingBetweenAddresses(f *testing.F) {
a1, _ := address.FromPubKey(ed25519.NewKeyFromSeed(make([]byte, 32)).Public().(ed25519.PublicKey))
seed2 := make([]byte, 32)
seed2[0] = 1
a2, _ := address.FromPubKey(ed25519.NewKeyFromSeed(seed2).Public().(ed25519.PublicKey))
f.Add(a1.String(), a2.String())
f.Fuzz(func(t *testing.T, s1, s2 string) {
x, err1 := address.Parse(s1)
y, err2 := address.Parse(s2)
if err1 != nil || err2 != nil {
return
}
sameString := s1 == s2
sameKey := x.KeyBytes() == y.KeyBytes()
if sameKey != sameString {
t.Fatalf("address/key correspondence is not one to one:\n%q\n%q\nsameKey=%v sameString=%v",
s1, s2, sameKey, sameString)
}
if x.Equal(y) != sameString {
t.Fatalf("Equal disagrees with string identity")
}
})
}

View file

@ -0,0 +1,30 @@
package address_test
import (
"testing"
"github.com/btcsuite/btcd/btcutil/bech32"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
// encodeRawForTest builds a syntactically valid bech32m trust address around
// an arbitrary payload, bypassing the validation that FromPubKey performs.
//
// It exists so that tests can construct the strings an attacker would send and
// confirm that Parse rejects them. Production code must never do this.
func encodeRawForTest(t *testing.T, version byte, key []byte) string {
t.Helper()
payload := make([]byte, 0, 1+len(key))
payload = append(payload, version)
payload = append(payload, key...)
conv, err := bech32.ConvertBits(payload, 8, 5, true)
if err != nil {
t.Fatalf("convert bits: %v", err)
}
s, err := bech32.EncodeM(address.HRP, conv)
if err != nil {
t.Fatalf("encode: %v", err)
}
return s
}

125
internal/address/pubkey.go Normal file
View file

@ -0,0 +1,125 @@
package address
import (
"bytes"
"crypto/ed25519"
"errors"
"filippo.io/edwards25519"
)
// Public key validation.
//
// Go's crypto/ed25519.Verify follows RFC 8032 and deliberately performs no
// checks on the public key beyond its length. That is correct for RFC 8032,
// but it is not sufficient for a protocol in which an attacker chooses the
// public key, because several classes of key make signatures meaningless:
//
// - The all-zero key encodes a point of order 4. The all-zero signature
// verifies against that key for *every* message. An attacker who
// registered such a key would own an identity whose signature verifies on
// any claim or approval anyone cares to construct, which is a direct
// break of INV-1.
//
// - The identity element (y = 1) behaves the same way.
//
// - Small-order and mixed-order points admit signatures that verify under
// more than one public key, destroying the "who said this" property that
// the entire protocol rests on.
//
// - Non-canonical encodings (field elements >= p) give two distinct byte
// strings that denote the same curve point, hence two distinct addresses
// for one identity, which violates INV-8.
//
// The protocol therefore requires every public key entering the system to be a
// canonically encoded point in the prime-order subgroup. The check is
// performed once, at address construction, so that every Address in the
// program has already been validated and no later code has to remember to do
// it.
var (
// ErrKeySize is returned when a key is not 32 bytes.
ErrKeySize = errors.New("address: public key must be 32 bytes")
// ErrKeyNotOnCurve is returned when the key does not decode to a valid
// Edwards25519 point.
ErrKeyNotOnCurve = errors.New("address: public key is not a valid curve point")
// ErrKeyNonCanonical is returned when the key is a valid point encoded in
// a non-canonical way (a field element that is not fully reduced).
ErrKeyNonCanonical = errors.New("address: public key encoding is non-canonical")
// ErrKeySmallOrder is returned when the key lies in the small-order
// torsion subgroup, for which signatures are forgeable or ambiguous.
ErrKeySmallOrder = errors.New("address: public key has small order")
)
// ValidatePubKey reports whether pub is usable as a trust identity key.
//
// It returns nil only for a 32-byte, canonically encoded Edwards25519 point
// that is not annihilated by multiplication by the cofactor. Honest keys
// produced by ed25519.GenerateKey always satisfy this.
func ValidatePubKey(pub []byte) error {
if len(pub) != ed25519.PublicKeySize {
return ErrKeySize
}
p, err := new(edwards25519.Point).SetBytes(pub)
if err != nil {
// SetBytes rejects non-canonical field encodings and points that are
// not on the curve. Both are fatal, but they are distinguishable by
// re-encoding below only for the on-curve case, so report the generic
// curve error unless we can say something more precise.
if isNonCanonicalEncoding(pub) {
return ErrKeyNonCanonical
}
return ErrKeyNotOnCurve
}
// A canonical point re-encodes to exactly the bytes it came from. Any
// difference means the input was an alternative spelling of this point.
if !bytes.Equal(p.Bytes(), pub) {
return ErrKeyNonCanonical
}
// Multiplying by the cofactor (8) maps every small-order point to the
// identity. A key in the prime-order subgroup never maps to the identity,
// because the group has prime order and the key is not itself the
// identity.
if new(edwards25519.Point).MultByCofactor(p).Equal(edwards25519.NewIdentityPoint()) == 1 {
return ErrKeySmallOrder
}
return nil
}
// isNonCanonicalEncoding reports whether the y coordinate encoded in pub is
// greater than or equal to the field prime p = 2^255 - 19. Such an encoding is
// rejected outright; it exists only to produce a second byte string for a
// point that already has a canonical encoding.
func isNonCanonicalEncoding(pub []byte) bool {
if len(pub) != ed25519.PublicKeySize {
return false
}
// Little-endian comparison against p, ignoring the sign bit in the MSB.
var y [32]byte
copy(y[:], pub)
y[31] &= 0x7f
// p = 2^255 - 19 little-endian.
var prime = [32]byte{
0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f,
}
for i := 31; i >= 0; i-- {
if y[i] < prime[i] {
return false
}
if y[i] > prime[i] {
return true
}
}
// Exactly equal to p is also non-canonical.
return true
}

View file

@ -0,0 +1,148 @@
package address_test
import (
"crypto/ed25519"
"encoding/hex"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
// smallOrderKeys is the standard list of Edwards25519 points of order 1, 2, 4
// and 8, plus the non-canonical encodings of some of them. These are the keys
// for which Ed25519 signature verification is degenerate.
//
// The list is the one used by libsodium's crypto_core_ed25519_is_valid_point
// test vectors and by RFC 8032 implementation reports.
var smallOrderKeys = []struct {
name string
hex string
}{
{"order 1 (identity)", "0100000000000000000000000000000000000000000000000000000000000000"},
{"order 2", "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"},
{"order 4 (all zero)", "0000000000000000000000000000000000000000000000000000000000000000"},
{"order 4 (sign bit)", "0000000000000000000000000000000000000000000000000000000000000080"},
{"order 8 a", "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05"},
{"order 8 b", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa"},
{"order 8 c", "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85"},
{"order 8 d", "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a"},
{"non-canonical p", "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"},
{"non-canonical p+1", "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"},
}
// TestRejectsSmallOrderKeys is the most important test in this package.
//
// crypto/ed25519.Verify accepts these keys and, for several of them, verifies
// an all-zero signature against ANY message. If such a key could be turned
// into a trust address, its owner would hold an identity whose signature
// verifies on every claim and every approval anyone constructs. That is a
// direct break of INV-1 and of the whole "cryptography establishes who said
// something" premise.
func TestRejectsSmallOrderKeys(t *testing.T) {
for _, tc := range smallOrderKeys {
t.Run(tc.name, func(t *testing.T) {
key, err := hex.DecodeString(tc.hex)
if err != nil {
t.Fatal(err)
}
if err := address.ValidatePubKey(key); err == nil {
t.Fatalf("ValidatePubKey accepted a degenerate key")
}
if _, err := address.FromPubKey(key); err == nil {
t.Fatalf("FromPubKey produced an address for a degenerate key")
}
})
}
}
// TestDegenerateKeyWouldForgeSignatures demonstrates the concrete attack the
// validation prevents, so that a future reader cannot mistake the check for
// paranoia and remove it.
func TestDegenerateKeyWouldForgeSignatures(t *testing.T) {
// The order-1 point (the identity element, y = 1). Signing with R set to
// the same encoding and S = 0 yields a signature that verifies under
// crypto/ed25519 for every message without exception.
key, err := hex.DecodeString("0100000000000000000000000000000000000000000000000000000000000000")
if err != nil {
t.Fatal(err)
}
universalSig := make([]byte, ed25519.SignatureSize)
copy(universalSig[:32], key)
messages := []string{
"trust1abc says trust1bca minecraft.op = true",
"trust1abc asks trust1bca to ban Steve",
"an entirely unrelated statement",
"",
}
forgedAll := true
for _, m := range messages {
if !ed25519.Verify(key, []byte(m), universalSig) {
forgedAll = false
break
}
}
if forgedAll {
t.Log("crypto/ed25519.Verify accepts one fixed signature under this key " +
"for every message tested: a universal forgery")
} else {
t.Log("stdlib no longer exhibits the universal forgery; validation is still required")
}
// Whatever the stdlib does, this key must never become a trust identity.
if err := address.ValidatePubKey(key); err == nil {
t.Fatal("degenerate key must be rejected before it can become an identity")
}
if _, err := address.FromPubKey(key); err == nil {
t.Fatal("FromPubKey must refuse the degenerate key")
}
// And it must not be smuggled in through the textual form either.
if _, err := address.Parse(encodeRawForTest(t, address.Version0, key)); err == nil {
t.Fatal("Parse must refuse an address wrapping the degenerate key")
}
}
func TestRejectsWrongKeySize(t *testing.T) {
for _, n := range []int{0, 1, 31, 33, 64} {
if err := address.ValidatePubKey(make([]byte, n)); err == nil {
t.Errorf("accepted %d-byte key", n)
}
}
if err := address.ValidatePubKey(nil); err == nil {
t.Error("accepted nil key")
}
}
// TestAcceptsGeneratedKeys ensures the validation never rejects an honest key.
// A false positive here would randomly break real users, so the sample is
// large enough to catch a rate on the order of one in a thousand.
func TestAcceptsGeneratedKeys(t *testing.T) {
const n = 3000
for i := 0; i < n; i++ {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
if err := address.ValidatePubKey(pub); err != nil {
t.Fatalf("rejected an honestly generated key: %v (%x)", err, pub)
}
if _, err := address.FromPubKey(pub); err != nil {
t.Fatalf("FromPubKey rejected an honest key: %v", err)
}
}
t.Logf("accepted %d generated keys, rejected 0", n)
}
// TestParseRejectsEmbeddedSmallOrderKey checks that the validation cannot be
// bypassed by encoding a degenerate key into a well-formed bech32m address.
func TestParseRejectsEmbeddedSmallOrderKey(t *testing.T) {
for _, tc := range smallOrderKeys {
key, _ := hex.DecodeString(tc.hex)
s := encodeRawForTest(t, address.Version0, key)
if _, err := address.Parse(s); err == nil {
t.Fatalf("Parse accepted an address wrapping a degenerate key (%s): %s", tc.name, s)
}
}
}

136
internal/identity/alias.go Normal file
View file

@ -0,0 +1,136 @@
package identity
import (
"errors"
"strings"
"unicode"
"unicode/utf8"
)
// Aliases (INV-7).
//
// An alias is a human-readable label that an identity asserts about itself. It
// is decoration for user interfaces and nothing else:
//
// - It is not unique. Two identities may claim the same alias, and the
// protocol does nothing to prevent that.
// - It is not verified. "Self-asserted" means exactly that; no one checks it.
// - It never participates in signature verification or in any authorization
// decision.
//
// Alias spoofing is therefore not a protocol vulnerability but a user
// interface concern. The mitigation is presentational: never show an alias
// without the address it belongs to. Display code should render
//
// NikoCraft (trust1q...w4np)
//
// and never "NikoCraft" alone.
//
// The validation here is not a security control. It exists to bound size and
// to strip characters that let one alias impersonate another visually or
// corrupt terminal output.
const (
// MaxAliasLen is the maximum length of an alias in bytes.
MaxAliasLen = 64
// MaxAliasRunes is the maximum length of an alias in runes, bounding the
// visual width independently of UTF-8 encoding length.
MaxAliasRunes = 32
)
var (
// ErrAliasTooLong is returned for an alias exceeding the size limits.
ErrAliasTooLong = errors.New("identity: alias too long")
// ErrAliasInvalidUTF8 is returned for an alias that is not valid UTF-8.
ErrAliasInvalidUTF8 = errors.New("identity: alias is not valid UTF-8")
// ErrAliasControlChar is returned for an alias containing control,
// bidirectional-override or other non-printing characters.
ErrAliasControlChar = errors.New("identity: alias contains a disallowed character")
// ErrAliasWhitespace is returned for an alias with leading or trailing
// whitespace, which would otherwise create look-alike aliases.
ErrAliasWhitespace = errors.New("identity: alias has leading or trailing whitespace")
)
// Alias is a validated display label. Its zero value is the empty alias, which
// is always acceptable: an identity is under no obligation to name itself.
type Alias struct {
s string
}
// ParseAlias validates a self-asserted display label.
//
// An empty alias is valid and yields the zero Alias.
func ParseAlias(s string) (Alias, error) {
if s == "" {
return Alias{}, nil
}
if len(s) > MaxAliasLen {
return Alias{}, ErrAliasTooLong
}
if !utf8.ValidString(s) {
return Alias{}, ErrAliasInvalidUTF8
}
if utf8.RuneCountInString(s) > MaxAliasRunes {
return Alias{}, ErrAliasTooLong
}
if strings.TrimSpace(s) != s {
return Alias{}, ErrAliasWhitespace
}
for _, r := range s {
if !allowedAliasRune(r) {
return Alias{}, ErrAliasControlChar
}
}
return Alias{s: s}, nil
}
// allowedAliasRune reports whether r may appear in an alias.
//
// Rejected: control characters, format characters (which include the
// bidirectional overrides U+202A..U+202E and U+2066..U+2069 used to make text
// render in a misleading order), unassigned code points, surrogates, private
// use characters, and every space character other than a plain ASCII space.
func allowedAliasRune(r rune) bool {
if r == ' ' {
return true
}
if unicode.IsControl(r) || unicode.IsSpace(r) {
return false
}
if unicode.In(r, unicode.Cf, unicode.Cs, unicode.Co, unicode.Cn) {
return false
}
return unicode.IsPrint(r)
}
// String returns the alias text.
func (a Alias) String() string { return a.s }
// IsEmpty reports whether the alias is unset.
func (a Alias) IsEmpty() bool { return a.s == "" }
// Display renders an identity for a user interface.
//
// The address is always included, because an alias on its own is not evidence
// of anything. Callers must not build their own alias-only display strings.
func Display(alias Alias, id Identity) string {
addr := id.String()
if alias.IsEmpty() {
return addr
}
return alias.String() + " (" + shortAddress(addr) + ")"
}
// shortAddress abbreviates an address for display while keeping enough of both
// ends to make substitution visible.
func shortAddress(addr string) string {
const head, tail = 10, 6
if len(addr) <= head+tail+1 {
return addr
}
return addr[:head] + "\u2026" + addr[len(addr)-tail:]
}

View file

@ -0,0 +1,186 @@
package identity_test
import (
"strings"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
)
func TestAliasAccepts(t *testing.T) {
for _, s := range []string{
"",
"Niko",
"NikoCraft",
"niko craft",
"Niko-Craft_2",
"[VIP] Niko",
"Ник",
"日本語",
"a",
strings.Repeat("a", 32),
} {
a, err := identity.ParseAlias(s)
if err != nil {
t.Errorf("ParseAlias(%q) = %v", s, err)
continue
}
if a.String() != s {
t.Errorf("alias altered: got %q want %q", a.String(), s)
}
}
}
func TestAliasRejects(t *testing.T) {
tests := []struct {
name string
in string
}{
{"too many runes", strings.Repeat("a", 33)},
{"too many bytes", strings.Repeat("é", 40)},
{"leading space", " Niko"},
{"trailing space", "Niko "},
{"newline", "Niko\nCraft"},
{"carriage return", "Niko\rCraft"},
{"tab", "Niko\tCraft"},
{"nul", "Niko\x00"},
{"escape", "Niko\x1b[31m"},
{"invalid utf8", "Niko\xff\xfe"},
{"zero width space", "Ni\u200bko"},
{"zero width joiner", "Ni\u200dko"},
{"rtl override", "Niko\u202eEVIL"},
{"lrm", "Niko\u200e"},
{"isolate", "Niko\u2066EVIL\u2069"},
{"non breaking space", "Niko\u00a0Craft"},
{"ideographic space", "Niko\u3000Craft"},
{"private use", "Niko\uf8ff"},
{"unassigned", "Niko\U000e0001"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := identity.ParseAlias(tc.in); err == nil {
t.Fatalf("accepted %q", tc.in)
}
})
}
}
// TestAliasIsNotUnique documents that alias collision is expected and is not
// treated as an error. Two identities may legitimately claim the same label;
// the address is what distinguishes them (INV-7).
func TestAliasIsNotUnique(t *testing.T) {
a, err := identity.ParseAlias("NikoCraft")
if err != nil {
t.Fatal(err)
}
b, err := identity.ParseAlias("NikoCraft")
if err != nil {
t.Fatal(err)
}
if a.String() != b.String() {
t.Fatal("expected the impersonating alias to be accepted verbatim")
}
}
// TestDisplayAlwaysShowsAddress is the mitigation for alias spoofing. An alias
// must never be rendered on its own, or one identity could visually
// impersonate another in an approval prompt.
func TestDisplayAlwaysShowsAddress(t *testing.T) {
victim := mustSigner(t).Identity()
attacker := mustSigner(t).Identity()
alias, err := identity.ParseAlias("NikoCraft")
if err != nil {
t.Fatal(err)
}
victimText := identity.Display(alias, victim)
attackerText := identity.Display(alias, attacker)
if victimText == attackerText {
t.Fatal("two identities with the same alias rendered identically")
}
for _, s := range []string{victimText, attackerText} {
if !strings.Contains(s, "trust1") {
t.Fatalf("display output omits the address: %q", s)
}
}
// An empty alias must still render the full address.
var empty identity.Alias
if got := identity.Display(empty, victim); got != victim.String() {
t.Fatalf("empty alias display = %q, want the full address", got)
}
t.Logf("victim: %s", victimText)
t.Logf("attacker: %s", attackerText)
}
// TestAliasNeverAffectsVerification asserts, at the type level and
// behaviourally, that aliases play no part in signature checking.
func TestAliasNeverAffectsVerification(t *testing.T) {
s := mustSigner(t)
id := s.Identity()
msg := []byte("canonical bytes")
sig := s.Sign(msg)
// Whatever alias anyone asserts, verification is unchanged. There is
// deliberately no API that would even accept an alias here.
for _, name := range []string{"", "Niko", "NikoCraft", "attacker"} {
alias, err := identity.ParseAlias(name)
if err != nil {
t.Fatal(err)
}
_ = alias
if !id.Verify(msg, sig) {
t.Fatal("verification result depends on ambient alias state")
}
}
}
func FuzzParseAlias(f *testing.F) {
for _, s := range []string{"", "Niko", "NikoCraft", " x", "x ", "\u202e", "\xff", strings.Repeat("a", 100)} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
a, err := identity.ParseAlias(s)
if err != nil {
return
}
// An accepted alias is returned verbatim: no silent normalisation,
// so what a user reviews is what was stored.
if a.String() != s {
t.Fatalf("alias normalised: %q -> %q", s, a.String())
}
if len(s) > identity.MaxAliasLen {
t.Fatalf("accepted an over-long alias (%d bytes)", len(s))
}
if !utf8Valid(s) {
t.Fatalf("accepted invalid UTF-8")
}
for _, r := range s {
if r == '\n' || r == '\r' || r == 0 || r == 0x1b {
t.Fatalf("accepted a control character %q", r)
}
}
if strings.TrimSpace(s) != s {
t.Fatalf("accepted surrounding whitespace")
}
// Parsing is idempotent.
again, err := identity.ParseAlias(a.String())
if err != nil || again.String() != a.String() {
t.Fatalf("ParseAlias is not idempotent: %v", err)
}
})
}
func utf8Valid(s string) bool {
for _, r := range s {
if r == '\uFFFD' && !strings.Contains(s, "\uFFFD") {
return false
}
}
return true
}

View file

@ -0,0 +1,124 @@
package identity_test
import (
"crypto/ed25519"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
)
// FuzzVerifyNeverPanics asserts that verification is total over arbitrary
// message and signature bytes, and that it never accepts anything the signer
// did not actually sign.
func FuzzVerifyNeverPanics(f *testing.F) {
s, err := signer.Generate()
if err != nil {
f.Fatal(err)
}
msg := []byte("seed message")
f.Add(msg, s.Sign(msg))
f.Add([]byte{}, []byte{})
f.Add([]byte("x"), make([]byte, 64))
f.Add(make([]byte, 1024), make([]byte, 63))
id := s.Identity()
f.Fuzz(func(t *testing.T, msg, sig []byte) {
ok := id.Verify(msg, sig)
if ok != (id.VerifyErr(msg, sig) == nil) {
t.Fatal("Verify and VerifyErr disagree")
}
if !ok {
return
}
// An accepted signature must be exactly 64 bytes and must be the one
// the signer produces for this message. Finding any other accepted
// pair would mean a forgery.
if len(sig) != ed25519.SignatureSize {
t.Fatalf("accepted a %d-byte signature", len(sig))
}
expected := s.Sign(msg)
if string(expected) != string(sig) {
t.Fatalf("accepted a signature the signer would not produce\nmsg=%x\nsig=%x", msg, sig)
}
})
}
// FuzzSignVerifyRoundTrip asserts that every message a signer signs verifies
// under its own identity and under no other.
func FuzzSignVerifyRoundTrip(f *testing.F) {
f.Add([]byte("hello"), make([]byte, ed25519.SeedSize))
f.Add([]byte(""), []byte("0123456789abcdef0123456789abcdef"))
other, err := signer.Generate()
if err != nil {
f.Fatal(err)
}
f.Fuzz(func(t *testing.T, msg, seed []byte) {
if len(seed) != ed25519.SeedSize {
return
}
s, err := signer.FromSeed(seed)
if err != nil {
t.Fatalf("FromSeed rejected a %d-byte seed: %v", len(seed), err)
}
sig := s.Sign(msg)
if !s.Identity().Verify(msg, sig) {
t.Fatal("a signature did not verify under its own identity")
}
if other.Identity().Verify(msg, sig) {
t.Fatal("a signature verified under a foreign identity")
}
// Signing is deterministic for Ed25519, which the protocol relies on
// when comparing objects for equality.
if string(s.Sign(msg)) != string(sig) {
t.Fatal("signing is not deterministic")
}
// Any single-bit change to the message must break verification.
if len(msg) > 0 {
bad := make([]byte, len(msg))
copy(bad, msg)
bad[0] ^= 0x80
if s.Identity().Verify(bad, sig) {
t.Fatal("verified a modified message")
}
}
})
}
// FuzzIdentityParse asserts that identity parsing inherits every guarantee of
// address parsing and never yields a usable identity from invalid text.
func FuzzIdentityParse(f *testing.F) {
s, _ := signer.Generate()
f.Add(s.Identity().String())
f.Add("")
f.Add("trust1")
f.Add("trust1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")
f.Fuzz(func(t *testing.T, str string) {
id, err := identity.Parse(str)
if err != nil {
if !id.IsZero() {
t.Fatal("an error result carried a non-zero identity")
}
return
}
if id.IsZero() {
t.Fatal("a successful parse produced the zero identity")
}
if id.String() != str {
t.Fatalf("parse normalised input: %q -> %q", str, id.String())
}
// A parsed identity must never verify a signature that was not made
// for it. It has no private key anywhere in the process, so nothing
// should verify except by astronomically unlikely chance.
if id.Verify([]byte("arbitrary"), make([]byte, ed25519.SignatureSize)) {
t.Fatal("a parsed identity verified an all-zero signature")
}
})
}

View file

@ -0,0 +1,150 @@
// Package identity defines a trust identity: an Ed25519 public key, its
// canonical trust address, and signature verification over that key.
//
// A trust identity is self-sufficient. It is created by generating a key pair,
// not by registering with anyone, and it carries no issuer, no certificate and
// no server-assigned attributes. There is no account type: a person, a game
// server, a daemon and a script are all just identities.
//
// Normative invariants enforced here:
//
// - INV-1: this package contains no signing capability whatsoever. It can
// verify, never produce. The server links only this package, so a full
// compromise of the server yields no ability to forge anything.
// - INV-2: there is no distinguished "trust.n1ko.dev" identity. Nothing in
// this package can mark one identity as more authoritative than another.
// - INV-3: identity is the public key. There is no numeric or database
// identifier anywhere in this type.
// - INV-5: this package answers "who signed this", never "is this allowed".
// There is deliberately no permission, role or capability concept.
// - INV-6: identities have no relationships here. Nothing links one identity
// to another, so no transitive trust can be derived at this layer.
// - INV-7: an Alias is a display label. It is not part of Identity, does not
// affect Equal, and never reaches any verification routine.
// - INV-9: imports are limited to the standard library and the address
// package. No storage, transport or application semantics.
//
// Private keys are handled exclusively by the client-only subpackage
// identity/signer. Server-side code must not import it.
package identity
import (
"crypto/ed25519"
"errors"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
// ErrInvalidSignature is returned when a signature does not verify.
var ErrInvalidSignature = errors.New("identity: invalid signature")
// SignatureSize is the size of an Ed25519 signature in bytes.
const SignatureSize = ed25519.SignatureSize
// Identity is a validated public identity.
//
// The zero Identity is invalid. A non-zero Identity always holds a public key
// that has passed curve validation, because the only ways to construct one go
// through the address package.
//
// Identity is comparable and safe to use as a map key.
type Identity struct {
addr address.Address
}
// FromAddress builds an Identity from an already validated address.
func FromAddress(a address.Address) (Identity, error) {
if a.IsZero() {
return Identity{}, address.ErrEmpty
}
return Identity{addr: a}, nil
}
// FromPubKey builds an Identity from an Ed25519 public key, validating the key
// as a canonical prime-order curve point.
func FromPubKey(pub ed25519.PublicKey) (Identity, error) {
a, err := address.FromPubKey(pub)
if err != nil {
return Identity{}, err
}
return Identity{addr: a}, nil
}
// Parse builds an Identity from a textual trust address.
func Parse(s string) (Identity, error) {
a, err := address.Parse(s)
if err != nil {
return Identity{}, err
}
return Identity{addr: a}, nil
}
// MustParse is Parse for constants and fixtures. It panics on error.
func MustParse(s string) Identity {
id, err := Parse(s)
if err != nil {
panic("identity: MustParse: " + err.Error())
}
return id
}
// Address returns the identity's trust address.
func (i Identity) Address() address.Address { return i.addr }
// String returns the canonical trust address text.
func (i Identity) String() string { return i.addr.String() }
// PubKey returns a copy of the identity's public key.
func (i Identity) PubKey() ed25519.PublicKey { return i.addr.PubKey() }
// IsZero reports whether i is the unset zero value.
func (i Identity) IsZero() bool { return i.addr.IsZero() }
// Equal reports whether two identities are the same key.
//
// Aliases are intentionally not considered (INV-7).
func (i Identity) Equal(other Identity) bool { return i.addr.Equal(other.addr) }
// Verify reports whether sig is a valid signature by this identity over msg.
//
// The message passed here must be the canonical encoding of a protocol object,
// never a JSON document or any other ambiguous representation (INV-8). This
// package cannot enforce that on its own; the protocol layer is responsible
// for only ever calling Verify with canonical bytes.
//
// Verify returns a boolean rather than an error so that callers cannot
// accidentally treat a non-nil error as "verified". Use VerifyErr when an
// error value is more convenient.
func (i Identity) Verify(msg, sig []byte) bool {
if i.IsZero() {
return false
}
if len(sig) != SignatureSize {
return false
}
// The key was validated at construction, so ed25519.Verify cannot be fed
// a small-order or non-canonical key through this path.
return ed25519.Verify(i.addr.PubKey(), msg, sig)
}
// VerifyErr is Verify returning ErrInvalidSignature instead of false.
func (i Identity) VerifyErr(msg, sig []byte) error {
if !i.Verify(msg, sig) {
return ErrInvalidSignature
}
return nil
}
// MarshalText implements encoding.TextMarshaler.
func (i Identity) MarshalText() ([]byte, error) { return i.addr.MarshalText() }
// UnmarshalText implements encoding.TextUnmarshaler, running full address
// validation.
func (i *Identity) UnmarshalText(b []byte) error {
var a address.Address
if err := a.UnmarshalText(b); err != nil {
return err
}
i.addr = a
return nil
}

View file

@ -0,0 +1,346 @@
package identity_test
import (
"crypto/ed25519"
"encoding/json"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
)
func mustSigner(t *testing.T) *signer.Signer {
t.Helper()
s, err := signer.Generate()
if err != nil {
t.Fatal(err)
}
return s
}
func TestGenerateProducesUsableIdentity(t *testing.T) {
s := mustSigner(t)
id := s.Identity()
if id.IsZero() {
t.Fatal("generated identity is zero")
}
if _, err := identity.Parse(id.String()); err != nil {
t.Fatalf("generated address does not parse: %v", err)
}
if err := address.ValidatePubKey(id.PubKey()); err != nil {
t.Fatalf("generated key fails validation: %v", err)
}
if !id.Address().Equal(s.Address()) {
t.Fatal("signer address and identity address disagree")
}
}
func TestGenerateIsUnique(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 200; i++ {
s := mustSigner(t)
addr := s.Identity().String()
if seen[addr] {
t.Fatalf("duplicate identity generated: %s", addr)
}
seen[addr] = true
}
}
func TestSignAndVerify(t *testing.T) {
s := mustSigner(t)
id := s.Identity()
msg := []byte("canonical protocol bytes")
sig := s.Sign(msg)
if len(sig) != identity.SignatureSize {
t.Fatalf("signature size = %d", len(sig))
}
if !id.Verify(msg, sig) {
t.Fatal("valid signature did not verify")
}
if err := id.VerifyErr(msg, sig); err != nil {
t.Fatalf("VerifyErr on a valid signature: %v", err)
}
}
// TestVerifyRejectsTampering covers the modification cases in the threat
// model: a changed message, a changed signature, and a signature made by a
// different identity.
func TestVerifyRejectsTampering(t *testing.T) {
s := mustSigner(t)
other := mustSigner(t)
id := s.Identity()
msg := []byte("trust1abc says trust1bca minecraft.op = true")
sig := s.Sign(msg)
t.Run("modified message", func(t *testing.T) {
for i := range msg {
bad := make([]byte, len(msg))
copy(bad, msg)
bad[i] ^= 0x01
if id.Verify(bad, sig) {
t.Fatalf("verified a message modified at byte %d", i)
}
}
})
t.Run("modified signature", func(t *testing.T) {
for i := range sig {
bad := make([]byte, len(sig))
copy(bad, sig)
bad[i] ^= 0x01
if id.Verify(msg, bad) {
t.Fatalf("verified a signature modified at byte %d", i)
}
}
})
t.Run("wrong signer", func(t *testing.T) {
if other.Identity().Verify(msg, sig) {
t.Fatal("a signature verified under the wrong identity")
}
if id.Verify(msg, other.Sign(msg)) {
t.Fatal("another identity's signature verified under this one")
}
})
t.Run("truncated and extended", func(t *testing.T) {
if id.Verify(msg, sig[:len(sig)-1]) {
t.Fatal("verified a truncated signature")
}
if id.Verify(msg, append(append([]byte{}, sig...), 0)) {
t.Fatal("verified an over-long signature")
}
if id.Verify(msg, nil) {
t.Fatal("verified a nil signature")
}
if id.Verify(msg, []byte{}) {
t.Fatal("verified an empty signature")
}
})
t.Run("empty message is still bound", func(t *testing.T) {
empty := s.Sign(nil)
if !id.Verify(nil, empty) {
t.Fatal("signature over an empty message did not verify")
}
if id.Verify([]byte("x"), empty) {
t.Fatal("empty-message signature verified over other content")
}
})
}
// TestZeroIdentityVerifiesNothing ensures an uninitialised Identity cannot be
// used to accept data. Fail-closed behaviour matters because a zero value is
// what a struct field holds when a caller forgets to populate it.
func TestZeroIdentityVerifiesNothing(t *testing.T) {
var zero identity.Identity
s := mustSigner(t)
msg := []byte("m")
if !zero.IsZero() {
t.Fatal("zero value must report IsZero")
}
if zero.Verify(msg, s.Sign(msg)) {
t.Fatal("the zero identity verified a signature")
}
if zero.Verify(nil, nil) {
t.Fatal("the zero identity verified empty input")
}
if err := zero.VerifyErr(msg, s.Sign(msg)); err == nil {
t.Fatal("VerifyErr on the zero identity returned nil")
}
if zero.Equal(zero) {
t.Fatal("the zero identity must not equal itself")
}
}
func TestEqualIgnoresAlias(t *testing.T) {
s := mustSigner(t)
a, err := identity.FromPubKey(s.Public())
if err != nil {
t.Fatal(err)
}
b, err := identity.Parse(s.Identity().String())
if err != nil {
t.Fatal(err)
}
if !a.Equal(b) {
t.Fatal("the same key must produce equal identities")
}
if !a.Equal(s.Identity()) {
t.Fatal("identity from signer differs from identity from key")
}
if a.Equal(mustSigner(t).Identity()) {
t.Fatal("different keys must not be equal")
}
}
func TestFromPubKeyRejectsBadKeys(t *testing.T) {
if _, err := identity.FromPubKey(make([]byte, 32)); err == nil {
t.Fatal("accepted the all-zero key")
}
if _, err := identity.FromPubKey(make([]byte, 31)); err == nil {
t.Fatal("accepted a short key")
}
if _, err := identity.FromPubKey(nil); err == nil {
t.Fatal("accepted a nil key")
}
}
func TestJSONRoundTrip(t *testing.T) {
s := mustSigner(t)
id := s.Identity()
type envelope struct {
Issuer identity.Identity `json:"issuer"`
}
b, err := json.Marshal(envelope{Issuer: id})
if err != nil {
t.Fatal(err)
}
var back envelope
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
if !back.Issuer.Equal(id) {
t.Fatal("identity did not survive a JSON round trip")
}
// Decoding must validate, so a hostile document cannot inject a
// degenerate key through the JSON path.
var bad envelope
if err := json.Unmarshal([]byte(`{"issuer":"trust1qqqqqqqqqqqqqqqq"}`), &bad); err == nil {
t.Fatal("unmarshal accepted an invalid address")
}
}
func TestPubKeyIsCopied(t *testing.T) {
s := mustSigner(t)
id := s.Identity()
k := id.PubKey()
for i := range k {
k[i] = 0
}
if err := address.ValidatePubKey(id.PubKey()); err != nil {
t.Fatalf("identity key was mutated through a returned copy: %v", err)
}
}
func TestSignerFromSeedAndPrivateKey(t *testing.T) {
orig := mustSigner(t)
seed := orig.Seed()
fromSeed, err := signer.FromSeed(seed)
if err != nil {
t.Fatal(err)
}
if !fromSeed.Identity().Equal(orig.Identity()) {
t.Fatal("seed did not restore the same identity")
}
fromPriv, err := signer.FromPrivateKey(orig.PrivateKey())
if err != nil {
t.Fatal(err)
}
if !fromPriv.Identity().Equal(orig.Identity()) {
t.Fatal("private key did not restore the same identity")
}
// Signatures from a restored key must verify under the original identity.
msg := []byte("restored")
if !orig.Identity().Verify(msg, fromSeed.Sign(msg)) {
t.Fatal("signature from a seed-restored key did not verify")
}
for _, n := range []int{0, 31, 33, 64} {
if _, err := signer.FromSeed(make([]byte, n)); err == nil {
t.Errorf("FromSeed accepted a %d-byte seed", n)
}
}
for _, n := range []int{0, 32, 63, 65} {
if _, err := signer.FromPrivateKey(make([]byte, n)); err == nil {
t.Errorf("FromPrivateKey accepted a %d-byte key", n)
}
}
}
// TestFromPrivateKeyRejectsInconsistentKey covers a tampered key file whose
// public half no longer matches its seed. Such a key would sign under one
// identity while claiming another.
func TestFromPrivateKeyRejectsInconsistentKey(t *testing.T) {
a := mustSigner(t)
b := mustSigner(t)
frankenstein := make(ed25519.PrivateKey, ed25519.PrivateKeySize)
copy(frankenstein[:32], a.PrivateKey()[:32]) // seed from a
copy(frankenstein[32:], b.Public()) // public half from b
if _, err := signer.FromPrivateKey(frankenstein); err == nil {
t.Fatal("accepted a private key whose public half does not match its seed")
}
}
func TestSeedAndPrivateKeyAreCopies(t *testing.T) {
s := mustSigner(t)
id := s.Identity()
seed := s.Seed()
for i := range seed {
seed[i] = 0
}
priv := s.PrivateKey()
for i := range priv {
priv[i] = 0
}
msg := []byte("still works")
if !id.Verify(msg, s.Sign(msg)) {
t.Fatal("signer was corrupted by a caller mutating returned key material")
}
}
func TestGenerateFromDeterministicSource(t *testing.T) {
src := func() *zeroReader { return &zeroReader{} }
a, err := signer.GenerateFrom(src())
if err != nil {
t.Fatal(err)
}
b, err := signer.GenerateFrom(src())
if err != nil {
t.Fatal(err)
}
if !a.Identity().Equal(b.Identity()) {
t.Fatal("the same entropy must yield the same identity")
}
if a.Identity().Equal(mustSigner(t).Identity()) {
t.Fatal("a random identity collided with the deterministic one")
}
}
type zeroReader struct{}
func (z *zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
func BenchmarkVerify(b *testing.B) {
s, _ := signer.Generate()
id := s.Identity()
msg := []byte("canonical protocol bytes for benchmarking")
sig := s.Sign(msg)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if !id.Verify(msg, sig) {
b.Fatal("verification failed")
}
}
}

View file

@ -0,0 +1,207 @@
package identity_test
import (
"os"
"os/exec"
"strings"
"testing"
)
// Invariant tests.
//
// These check properties of the source tree itself rather than of a running
// program. They exist because INV-1 and INV-9 are structural claims: they
// cannot be established by testing behaviour, only by showing that certain
// code is absent and that certain packages are not linked together.
//
// Expressing them as tests means a violation fails the build instead of
// silently contradicting a comment.
// requireToolchain skips the calling test when the Go toolchain and the module
// source are not available.
//
// These tests inspect the source tree, so they are meaningful only when run
// from a checkout with a working toolchain. A cross-compiled test binary
// executed on another machine, for example the Windows build run under an
// emulator, has neither, and must skip rather than report a failure that says
// nothing about the code.
func requireToolchain(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go toolchain not available; source-tree invariants are checked on the build host")
}
if _, err := os.Stat("invariants_test.go"); err != nil {
t.Skip("module source not available; source-tree invariants are checked on the build host")
}
}
// goList runs `go list` and returns its output lines.
func goList(t *testing.T, args ...string) []string {
t.Helper()
cmd := exec.Command("go", append([]string{"list"}, args...)...)
cmd.Env = append(cmd.Environ(), "GOPROXY=off", "GOFLAGS=-mod=mod")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go list %v: %v\n%s", args, err, out)
}
var lines []string
for _, l := range strings.Split(string(out), "\n") {
if l = strings.TrimSpace(l); l != "" {
lines = append(lines, l)
}
}
return lines
}
const modulePath = "git.n1ko.dev/Niko/niko_trust"
// TestProtocolLayerImports enforces INV-9.
//
// The protocol layer must depend on nothing but the standard library and
// vetted cryptographic primitives. If a protocol package ever imported the
// storage, transport or application layer, application semantics would leak
// into the definition of what a signed object means, and the layering that the
// whole design rests on would be gone.
func TestProtocolLayerImports(t *testing.T) {
requireToolchain(t)
// Only vetted cryptographic and encoding primitives are permitted, along
// with their own subpackages.
allowedExternalPrefixes := []string{
"github.com/btcsuite/btcd/btcutil/bech32",
"filippo.io/edwards25519",
}
allowedExternal := func(dep string) bool {
for _, p := range allowedExternalPrefixes {
if dep == p || strings.HasPrefix(dep, p+"/") {
return true
}
}
// Standard library internal packages are versioned in recent
// toolchains (for example crypto/internal/entropy/v1.0.0), which puts
// a dot in the path even though they are part of the stdlib.
return strings.HasPrefix(dep, "crypto/internal/") ||
strings.HasPrefix(dep, "internal/")
}
protocolPackages := []string{
modulePath + "/internal/address",
modulePath + "/internal/identity",
}
for _, pkg := range protocolPackages {
t.Run(pkg, func(t *testing.T) {
for _, dep := range goList(t, "-deps", pkg) {
switch {
case !strings.Contains(dep, "."):
// No dot in the first path element means stdlib.
continue
case allowedExternal(dep):
continue
case strings.HasPrefix(dep, modulePath+"/internal/address"),
strings.HasPrefix(dep, modulePath+"/internal/identity"):
// Protocol packages may depend on each other.
continue
case strings.HasPrefix(dep, "internal/"),
strings.HasPrefix(dep, "vendor/"),
strings.HasPrefix(dep, "golang.org/x/sys"),
strings.HasPrefix(dep, "golang.org/x/crypto"):
// Toolchain-internal and transitive runtime support.
continue
default:
t.Errorf("protocol package %s depends on %s, which is outside the protocol layer", pkg, dep)
}
}
})
}
}
// TestProtocolDoesNotImportSigner enforces the client/server split behind
// INV-1.
//
// The signer package is the only place a private key exists. Nothing in the
// protocol layer may reach it, so that a server binary built from the protocol
// and transport layers cannot contain signing capability even by accident.
func TestProtocolDoesNotImportSigner(t *testing.T) {
requireToolchain(t)
signerPkg := modulePath + "/internal/identity/signer"
for _, pkg := range []string{
modulePath + "/internal/address",
modulePath + "/internal/identity",
} {
for _, dep := range goList(t, "-deps", pkg) {
if dep == signerPkg {
t.Errorf("%s imports %s: the protocol layer must not have signing capability", pkg, signerPkg)
}
}
}
}
// TestNoSigningOutsideSigner enforces INV-1 at the source level: the ability
// to produce an Ed25519 signature must exist in exactly one package.
//
// Later stages will add server packages to this tree. Because the check scans
// every non-test file in the module, a server package that starts signing
// something will fail this test the moment it is written.
func TestNoSigningOutsideSigner(t *testing.T) {
requireToolchain(t)
files := goList(t, "-f",
`{{$d := .Dir}}{{range .GoFiles}}{{$d}}/{{.}}{{"\n"}}{{end}}`,
modulePath+"/...")
signingAPIs := []string{
"ed25519.Sign(",
"ed25519.GenerateKey(",
"ed25519.NewKeyFromSeed(",
}
for _, file := range files {
// The signer package is the one permitted home for private keys.
if strings.Contains(file, "/internal/identity/signer/") {
continue
}
data, err := readFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, api := range signingAPIs {
if strings.Contains(data, api) {
t.Errorf("%s uses %s outside the signer package; "+
"private key operations must stay in internal/identity/signer", file, api)
}
}
}
}
// TestNoApplicationSemantics enforces INV-5 and INV-9 from the other
// direction: the protocol layer must not know what any claim key means.
//
// The trust server is a relay for opaque statements. The moment a package
// branches on "minecraft.op" or grants a "permission", it has started making
// authorization decisions, which is exactly what the design forbids.
func TestNoApplicationSemantics(t *testing.T) {
requireToolchain(t)
files := goList(t, "-f",
`{{$d := .Dir}}{{range .GoFiles}}{{$d}}/{{.}}{{"\n"}}{{end}}`,
modulePath+"/...")
forbidden := []string{
"minecraft", "ssh.login", "server.restart",
"isAdmin", "IsAdmin", "hasPermission", "HasPermission",
"isAuthorized", "IsAuthorized", "checkPermission", "CheckPermission",
}
for _, file := range files {
data, err := readFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
lower := strings.ToLower(data)
for _, term := range forbidden {
if strings.Contains(lower, strings.ToLower(term)) {
t.Errorf("%s mentions %q: the trust layer must not contain "+
"application-specific semantics or authorization logic", file, term)
}
}
}
}

View file

@ -0,0 +1,182 @@
// Package signer holds the only private key handling in the tree.
//
// CLIENT-SIDE ONLY. Server-side packages must never import this package.
// The rule is mechanically enforced by TestServerPackagesHaveNoSigner in the
// api layer and by TestSignerIsNotReachableFromProtocol here, so a violation
// fails the build rather than merely contradicting a comment.
//
// The separation exists because of INV-1: a compromise of trust.n1ko.dev must
// not allow forging an identity, claim, approval or response. That property
// holds only if the server never possesses a signing key. Keeping the signing
// capability in a package the server does not link makes the guarantee
// structural instead of a matter of discipline.
//
// It also serves INV-2: the server is never an issuer. It has nothing to issue
// with.
package signer
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"errors"
"io"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
)
var (
// ErrSeedSize is returned when a seed is not 32 bytes.
ErrSeedSize = errors.New("signer: seed must be 32 bytes")
// ErrKeySize is returned when a private key is not 64 bytes.
ErrKeySize = errors.New("signer: private key must be 64 bytes")
// ErrKeyMismatch is returned when a private key's embedded public half
// does not match the public key derived from its seed. Such a key would
// produce signatures that do not verify under the advertised identity.
ErrKeyMismatch = errors.New("signer: private key is inconsistent")
)
// Signer owns an Ed25519 private key and can sign canonical protocol bytes.
//
// The zero Signer is unusable. Signer is deliberately not comparable to a
// public type and provides no accessor returning the raw private key other
// than the explicitly named Seed and PrivateKey methods, which exist only so
// that the keystore can persist the key.
type Signer struct {
priv ed25519.PrivateKey
id identity.Identity
}
// Generate creates a new identity and its private key using crypto/rand.
//
// The generated public key is validated exactly as an imported one would be.
// The probability of ed25519.GenerateKey producing a small-order key is
// negligible, but the check costs nothing and removes the need to reason about
// whether this path is special.
func Generate() (*Signer, error) {
return GenerateFrom(rand.Reader)
}
// GenerateFrom creates a new identity using the supplied entropy source.
//
// Callers outside tests should use Generate. Passing a source that is not
// cryptographically secure produces a key an attacker can reproduce.
func GenerateFrom(random io.Reader) (*Signer, error) {
if random == nil {
random = rand.Reader
}
pub, priv, err := ed25519.GenerateKey(random)
if err != nil {
return nil, err
}
return newSigner(priv, pub)
}
// FromSeed reconstructs a Signer from a 32-byte seed.
func FromSeed(seed []byte) (*Signer, error) {
if len(seed) != ed25519.SeedSize {
return nil, ErrSeedSize
}
priv := ed25519.NewKeyFromSeed(seed)
pub, ok := priv.Public().(ed25519.PublicKey)
if !ok {
return nil, ErrKeySize
}
return newSigner(priv, pub)
}
// FromPrivateKey reconstructs a Signer from a 64-byte Ed25519 private key.
//
// The key's embedded public half is checked against the public key derived
// from its seed. Without that check a malformed or tampered key file would
// yield a Signer whose signatures never verify under the identity it reports,
// which is a confusing failure to debug and a plausible way to trick a user
// into believing an action succeeded.
func FromPrivateKey(priv ed25519.PrivateKey) (*Signer, error) {
if len(priv) != ed25519.PrivateKeySize {
return nil, ErrKeySize
}
derived := ed25519.NewKeyFromSeed(priv.Seed())
if subtleCompare(derived, priv) != 1 {
return nil, ErrKeyMismatch
}
pub, ok := priv.Public().(ed25519.PublicKey)
if !ok {
return nil, ErrKeySize
}
return newSigner(priv, pub)
}
// newSigner validates the public half and builds the Signer.
func newSigner(priv ed25519.PrivateKey, pub ed25519.PublicKey) (*Signer, error) {
if err := address.ValidatePubKey(pub); err != nil {
return nil, err
}
id, err := identity.FromPubKey(pub)
if err != nil {
return nil, err
}
// Copy so that the caller's slice cannot be mutated underneath us.
stored := make(ed25519.PrivateKey, len(priv))
copy(stored, priv)
return &Signer{priv: stored, id: id}, nil
}
// Identity returns the public identity corresponding to the private key.
func (s *Signer) Identity() identity.Identity { return s.id }
// Address returns the signer's trust address.
func (s *Signer) Address() address.Address { return s.id.Address() }
// Public returns the public key.
func (s *Signer) Public() ed25519.PublicKey { return s.id.PubKey() }
// Sign signs msg with the private key.
//
// msg must be the canonical encoding of a protocol object, produced by the
// protocol layer, and must already carry that layer's domain separation tag.
// This package intentionally adds no framing of its own: adding a second,
// invisible layer of framing here would make the signed bytes depend on which
// code path produced them, which is precisely the ambiguity INV-8 forbids.
func (s *Signer) Sign(msg []byte) []byte {
return ed25519.Sign(s.priv, msg)
}
// SignerCrypto exposes the key as a crypto.Signer for interoperability.
func (s *Signer) SignerCrypto() crypto.Signer { return s.priv }
// Seed returns a copy of the 32-byte seed for persistence by the keystore.
//
// The name is blunt on purpose: a call site that reads Seed() is obviously
// handling secret material and should be scrutinised in review.
func (s *Signer) Seed() []byte {
seed := s.priv.Seed()
out := make([]byte, len(seed))
copy(out, seed)
return out
}
// PrivateKey returns a copy of the full private key for persistence.
func (s *Signer) PrivateKey() ed25519.PrivateKey {
out := make(ed25519.PrivateKey, len(s.priv))
copy(out, s.priv)
return out
}
// subtleCompare is a constant-time equality test returning 1 when equal.
func subtleCompare(a, b []byte) int {
if len(a) != len(b) {
return 0
}
var v byte
for i := range a {
v |= a[i] ^ b[i]
}
if v == 0 {
return 1
}
return 0
}

View file

@ -0,0 +1,12 @@
package identity_test
import "os"
// readFile reads a source file for the invariant tests.
func readFile(path string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(b), nil
}

View file

@ -0,0 +1,180 @@
package protocol_test
import (
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// TestAccessors covers the trivial Value/object accessors that the golden and
// rule tests do not call: Decision.String, and the Signature() copies returned
// by every decoder.
func TestAccessors(t *testing.T) {
if protocol.Allow.String() != "allow" || protocol.Deny.String() != "deny" {
t.Error("Decision.String")
}
if protocol.Decision(99).String() != "invalid" {
t.Error("invalid Decision.String")
}
if !protocol.Allow.Valid() || !protocol.Deny.Valid() || protocol.Decision(99).Valid() {
t.Error("Decision.Valid")
}
vf := loadVectors(t)
for _, v := range vf.Vectors {
b := mustHex(t, v.TCEHex)
sig := mustHex(t, v.SignatureHex)
check := func(o interface {
TCE() []byte
Signature() []byte
}) {
t.Helper()
if len(o.TCE()) == 0 {
t.Errorf("%s: TCE() empty", v.Name)
}
// Signature is only populated after VerifyX; before that it is
// nil, which is the intended behaviour. Calling it must not panic
// and, once set, must return an independent copy.
got := o.Signature()
if len(got) > 0 {
cp := append([]byte(nil), got...)
cp[0] ^= 0xff
if string(o.Signature()) == string(cp) {
t.Errorf("%s: Signature() not a stable copy", v.Name)
}
}
_ = sig
}
switch v.Name {
case "identity/nikocraft", "identity/niko":
o, err := protocol.DecodeIdentity(b)
if err != nil {
t.Fatal(err)
}
check(o)
_ = o
case "claim/boolean", "claim/all-value-types":
o, err := protocol.DecodeClaim(b)
if err != nil {
t.Fatal(err)
}
check(o)
case "revocation/boolean-claim":
o, err := protocol.DecodeRevocation(b)
if err != nil {
t.Fatal(err)
}
check(o)
case "approval_request/ban":
o, err := protocol.DecodeApprovalRequest(b)
if err != nil {
t.Fatal(err)
}
check(o)
case "approval_response/allow", "approval_response/deny":
o, err := protocol.DecodeApprovalResponse(b)
if err != nil {
t.Fatal(err)
}
check(o)
case "auth_assertion/ws":
o, err := protocol.DecodeAuthAssertion(b)
if err != nil {
t.Fatal(err)
}
check(o)
}
}
}
// TestValidateCurrentEdges exercises the clock-skew boundaries of section 13.1.
func TestValidateCurrentEdges(t *testing.T) {
const skew = protocol.MaxClockSkew
cases := []struct {
created, expires, now uint64
wantErr bool
}{
{1000, 2000, 1500, false},
{1000, 0, 1500, false}, // no expiry
{1000, 2000, 1000 - skew - 1, true}, // not yet valid (beyond skew)
{1000, 2000, 1000 - skew, false}, // exactly at skew: ok
{1000, 2000, 2000 + skew, false}, // still current within skew
{1000, 2000, 2000 + skew + 1, true}, // expired beyond skew
}
for i, c := range cases {
err := protocol.ValidateCurrent(c.created, c.expires, c.now)
if (err != nil) != c.wantErr {
t.Errorf("case %d: ValidateCurrent = %v, wantErr=%v", i, err, c.wantErr)
}
}
}
// TestClaimStatusAtEdges checks the three-way lifecycle reported by the
// protocol layer (active / expired; never "not found").
func TestClaimStatusAtEdges(t *testing.T) {
const skew = protocol.MaxClockSkew
mk := func(expires uint64) *protocol.Claim {
return &protocol.Claim{ExpiresAt: expires}
}
if protocol.ClaimStatusAt(nil, 0) != protocol.StatusActive {
t.Error("nil claim is active (caller decides absence)")
}
if protocol.ClaimStatusAt(mk(2000), 1500) != protocol.StatusActive {
t.Error("within window active")
}
if protocol.ClaimStatusAt(mk(2000), 2000+skew) != protocol.StatusActive {
t.Error("within skew active")
}
if protocol.ClaimStatusAt(mk(2000), 2000+skew+1) != protocol.StatusExpired {
t.Error("beyond skew expired")
}
if protocol.ClaimStatusAt(mk(0), 9999) != protocol.StatusActive {
t.Error("no expiry never expires")
}
}
// TestVerifyRevocationOfEdges exercises the issuer/claim binding errors.
func TestVerifyRevocationOfEdges(t *testing.T) {
// A revocation that does not bind to its claim must be rejected outright,
// even before any store lookup.
vf := loadVectors(t)
rev := byName(t, vf, "revocation/boolean-claim")
claim := byName(t, vf, "claim/boolean")
revObj, err := protocol.DecodeRevocation(mustHex(t, rev.TCEHex))
if err != nil {
t.Fatal(err)
}
claimObj, err := protocol.DecodeClaim(mustHex(t, claim.TCEHex))
if err != nil {
t.Fatal(err)
}
if err := protocol.VerifyRevocationOf(revObj, claimObj); err != nil {
t.Fatalf("valid binding rejected: %v", err)
}
if err := protocol.VerifyRevocationOf(nil, claimObj); err != protocol.ErrNil {
t.Errorf("nil rev: %v", err)
}
if err := protocol.VerifyRevocationOf(revObj, nil); err != protocol.ErrNil {
t.Errorf("nil claim: %v", err)
}
// A revocation whose embedded claim_id does not match the claim it
// targets must fail the binding, even with a valid issuer.
badRev := *revObj
var wrong tce.ID
wrong[0] = 0xff
badRev.ClaimID = wrong
if err := protocol.VerifyRevocationOf(&badRev, claimObj); err != protocol.ErrWrongClaim {
t.Errorf("wrong claim_id must fail binding, got %v", err)
}
// An issuer that is not the claim's issuer must also fail.
badIssuer := *revObj
var other [32]byte
other[0] = 0xab
badIssuer.Issuer = other[:]
if err := protocol.VerifyRevocationOf(&badIssuer, claimObj); err != protocol.ErrWrongIssuer {
t.Errorf("wrong issuer must fail binding, got %v", err)
}
}

339
internal/protocol/decode.go Normal file
View file

@ -0,0 +1,339 @@
package protocol
import (
"bytes"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Decoders for the six protocol objects.
//
// Each decoder reads the fields in the exact order PROTOCOL.md section 8
// lists them and enforces the whole-object size limit before touching the
// body. Identity public keys are validated as canonical curve points here,
// which is the step crypto/ed25519.Verify deliberately does not perform and
// therefore the one that must happen before any signature check
// (docs/PROTOCOL.md section 7.3).
//
// Every byte slice a decoder exposes is a copy: keys, nonces and the retained
// canonical bytes never alias the caller's buffer, so a decoded object cannot
// be altered after the fact by mutating the input.
// decodeIdentityField reads an identity field and validates its public key.
func decodeIdentityField(d *tce.Decoder, name string) ([]byte, error) {
pub, err := d.Identity()
if err != nil {
return nil, err
}
if err := address.ValidatePubKey(pub); err != nil {
return nil, fieldErr(name, err)
}
return pub, nil
}
// checkEnd asserts the object consumed the whole input: any byte after the
// last field would give the object a different ID and signature from the
// object it appears to contain.
func checkEnd(d *tce.Decoder) error {
return d.End()
}
// DecodeIdentity parses an IdentityRegistration from its canonical bytes.
func DecodeIdentity(b []byte) (*Identity, error) {
if len(b) > tce.MaxIdentityTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagIdentity {
return nil, ErrWrongObject
}
pub, err := decodeIdentityField(d, "identity")
if err != nil {
return nil, err
}
alias, err := d.String(tce.MaxAliasLen)
if err != nil {
return nil, fieldErr("alias", err)
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &Identity{PubKey: pub, Alias: alias, CreatedAt: createdAt}
o.tce = bytes.Clone(b)
return o, nil
}
// DecodeClaim parses a Claim from its canonical bytes.
func DecodeClaim(b []byte) (*Claim, error) {
if len(b) > tce.MaxClaimTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagClaim {
return nil, ErrWrongObject
}
issuer, err := decodeIdentityField(d, "issuer")
if err != nil {
return nil, err
}
subject, err := decodeIdentityField(d, "subject")
if err != nil {
return nil, err
}
claims, err := d.Map(1)
if err != nil {
return nil, fieldErr("claims", err)
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
expiresAt, err := d.Timestamp(true)
if err != nil {
return nil, fieldErr("expires_at", err)
}
if expiresAt != 0 && expiresAt <= createdAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
serial, err := d.Uvarint()
if err != nil {
return nil, fieldErr("serial", err)
}
nonce, err := d.FixedBytes(tce.NonceSize)
if err != nil {
return nil, fieldErr("nonce", err)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &Claim{
Issuer: issuer, Subject: subject, Claims: claims,
CreatedAt: createdAt, ExpiresAt: expiresAt, Serial: serial,
Nonce: bytes.Clone(nonce),
}
o.tce = bytes.Clone(b)
return o, nil
}
// DecodeRevocation parses a Revocation from its canonical bytes.
func DecodeRevocation(b []byte) (*Revocation, error) {
if len(b) > tce.MaxRevocTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagRevocation {
return nil, ErrWrongObject
}
issuer, err := decodeIdentityField(d, "issuer")
if err != nil {
return nil, err
}
claimHash, err := d.FixedBytes(tce.HashSize)
if err != nil {
return nil, fieldErr("claim_id", err)
}
claimID, err := tce.IDFromBytes(claimHash)
if err != nil {
return nil, fieldErr("claim_id", err)
}
reason, err := d.String(tce.MaxReasonLen)
if err != nil {
return nil, fieldErr("reason", err)
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
nonce, err := d.FixedBytes(tce.NonceSize)
if err != nil {
return nil, fieldErr("nonce", err)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &Revocation{
Issuer: issuer, ClaimID: claimID, Reason: reason,
CreatedAt: createdAt, Nonce: bytes.Clone(nonce),
}
o.tce = bytes.Clone(b)
return o, nil
}
// DecodeApprovalRequest parses an ApprovalRequest from its canonical bytes.
func DecodeApprovalRequest(b []byte) (*ApprovalRequest, error) {
if len(b) > tce.MaxRequestTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagApprovalRequest {
return nil, ErrWrongObject
}
sender, err := decodeIdentityField(d, "sender")
if err != nil {
return nil, err
}
recipient, err := decodeIdentityField(d, "recipient")
if err != nil {
return nil, err
}
action, err := d.String(tce.MaxActionLen)
if err != nil {
return nil, fieldErr("action", err)
}
payload, err := d.Map(0)
if err != nil {
return nil, fieldErr("payload", err)
}
message, err := d.String(tce.MaxMessageLen)
if err != nil {
return nil, fieldErr("message", err)
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
expiresAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("expires_at", err)
}
if expiresAt <= createdAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
if expiresAt-createdAt > tce.MaxApprovalLifetime {
return nil, fieldErr("expires_at", tce.ErrLifetime)
}
nonce, err := d.FixedBytes(tce.NonceSize)
if err != nil {
return nil, fieldErr("nonce", err)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &ApprovalRequest{
Sender: sender, Recipient: recipient, Action: action,
Payload: payload, Message: message, CreatedAt: createdAt,
ExpiresAt: expiresAt, Nonce: bytes.Clone(nonce),
}
o.tce = bytes.Clone(b)
return o, nil
}
// DecodeApprovalResponse parses an ApprovalResponse from its canonical bytes.
//
// This only parses. Binding the response to a particular request is the job
// of VerifyApprovalResponse, which requires the request.
func DecodeApprovalResponse(b []byte) (*ApprovalResponse, error) {
if len(b) > tce.MaxResponseTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagApprovalResponse {
return nil, ErrWrongObject
}
hashBytes, err := d.FixedBytes(tce.HashSize)
if err != nil {
return nil, fieldErr("request_hash", err)
}
requestHash, err := tce.IDFromBytes(hashBytes)
if err != nil {
return nil, fieldErr("request_hash", err)
}
responder, err := decodeIdentityField(d, "responder")
if err != nil {
return nil, err
}
decisionRaw, err := d.Uvarint()
if err != nil {
return nil, fieldErr("decision", err)
}
if Decision(decisionRaw) != Deny && Decision(decisionRaw) != Allow {
return nil, fieldErr("decision", tce.ErrDecision)
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
nonce, err := d.FixedBytes(tce.NonceSize)
if err != nil {
return nil, fieldErr("nonce", err)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &ApprovalResponse{
RequestHash: requestHash, Responder: responder,
Decision: Decision(decisionRaw), CreatedAt: createdAt,
Nonce: bytes.Clone(nonce),
}
o.tce = bytes.Clone(b)
return o, nil
}
// DecodeAuthAssertion parses an AuthAssertion from its canonical bytes.
func DecodeAuthAssertion(b []byte) (*AuthAssertion, error) {
if len(b) > tce.MaxAuthTCE {
return nil, tce.ErrObjectTooLarge
}
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return nil, err
}
if tag != tce.TagAuthAssertion {
return nil, ErrWrongObject
}
pub, err := decodeIdentityField(d, "identity")
if err != nil {
return nil, err
}
challenge, err := d.FixedBytes(tce.ChallengeSize)
if err != nil {
return nil, fieldErr("challenge", err)
}
scope, err := d.String(tce.MaxScopeLen)
if err != nil {
return nil, fieldErr("scope", err)
}
audience, err := d.String(tce.MaxAudienceLen)
if err != nil {
return nil, fieldErr("audience", err)
}
createdAt, err := d.Timestamp(false)
if err != nil {
return nil, fieldErr("created_at", err)
}
if err := checkEnd(d); err != nil {
return nil, err
}
o := &AuthAssertion{
PubKey: pub, Challenge: bytes.Clone(challenge), Scope: scope,
Audience: audience, CreatedAt: createdAt,
}
o.tce = bytes.Clone(b)
return o, nil
}

209
internal/protocol/encode.go Normal file
View file

@ -0,0 +1,209 @@
package protocol
import (
"fmt"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Encoders for the six protocol objects.
//
// Each encoder writes the fields in exactly the order given in PROTOCOL.md
// section 8 and enforces that section's limits on top of the primitive
// constraints that internal/tce already applies. There are no optional fields:
// every field is always present, an empty string encodes as a single 0x00 and
// an empty map as a single 0x00, so the encoding cannot drift between call
// sites.
// fieldErr annotates a sentinel error with the field that failed, without
// including any input data.
func fieldErr(field string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", field, err)
}
// finishEncode returns the encoder's bytes after applying the per-object whole
// limit from PROTOCOL.md section 6.3.
func finishEncode(e *tce.Encoder, limit int) ([]byte, error) {
b, err := e.Bytes()
if err != nil {
return nil, err
}
if len(b) > limit {
return nil, tce.ErrObjectTooLarge
}
return b, nil
}
// EncodeIdentity encodes an IdentityRegistration, object tag 0x01.
//
// Field order: identity, alias, created_at. The alias appears in this object
// and in no other (INV-7). The key is validated as a curve point before it is
// written, so a degenerate key can never be signed.
func EncodeIdentity(o *Identity) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.PubKey); err != nil {
return nil, fieldErr("identity", err)
}
e := tce.NewEncoder()
e.Header(tce.TagIdentity)
e.Identity("identity", o.PubKey)
e.String("alias", o.Alias, tce.MaxAliasLen)
e.Timestamp("created_at", o.CreatedAt, false)
return finishEncode(e, tce.MaxIdentityTCE)
}
// EncodeClaim encodes a Claim, object tag 0x02.
//
// Field order: issuer, subject, claims, created_at, expires_at, serial,
// nonce. expires_at of 0 means "does not expire"; otherwise it must be
// strictly after created_at.
func EncodeClaim(o *Claim) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Issuer); err != nil {
return nil, fieldErr("issuer", err)
}
if err := address.ValidatePubKey(o.Subject); err != nil {
return nil, fieldErr("subject", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
if o.ExpiresAt != 0 && o.ExpiresAt <= o.CreatedAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
e := tce.NewEncoder()
e.Header(tce.TagClaim)
e.Identity("issuer", o.Issuer)
e.Identity("subject", o.Subject)
e.Map("claims", o.Claims, 1)
e.Timestamp("created_at", o.CreatedAt, false)
e.Timestamp("expires_at", o.ExpiresAt, true)
e.Uvarint(o.Serial)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxClaimTCE)
}
// EncodeRevocation encodes a Revocation, object tag 0x03.
//
// Field order: issuer, claim_id, reason, created_at, nonce. The binding
// between a revocation and the claim it withdraws is enforced by
// VerifyRevocationOf once both objects are verified.
func EncodeRevocation(o *Revocation) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Issuer); err != nil {
return nil, fieldErr("issuer", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagRevocation)
e.Identity("issuer", o.Issuer)
e.FixedBytes("claim_id", o.ClaimID[:], tce.HashSize)
e.String("reason", o.Reason, tce.MaxReasonLen)
e.Timestamp("created_at", o.CreatedAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxRevocTCE)
}
// EncodeApprovalRequest encodes an ApprovalRequest, object tag 0x04.
//
// Field order: sender, recipient, action, payload, message, created_at,
// expires_at, nonce. expires_at must be after created_at by at most 60
// seconds; the bound is part of the format so an over-long request is invalid
// everywhere rather than merely refused by one server.
func EncodeApprovalRequest(o *ApprovalRequest) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Sender); err != nil {
return nil, fieldErr("sender", err)
}
if err := address.ValidatePubKey(o.Recipient); err != nil {
return nil, fieldErr("recipient", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
if o.ExpiresAt <= o.CreatedAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
if o.ExpiresAt-o.CreatedAt > tce.MaxApprovalLifetime {
return nil, fieldErr("expires_at", tce.ErrLifetime)
}
e := tce.NewEncoder()
e.Header(tce.TagApprovalRequest)
e.Identity("sender", o.Sender)
e.Identity("recipient", o.Recipient)
e.String("action", o.Action, tce.MaxActionLen)
e.Map("payload", o.Payload, 0)
e.String("message", o.Message, tce.MaxMessageLen)
e.Timestamp("created_at", o.CreatedAt, false)
e.Timestamp("expires_at", o.ExpiresAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxRequestTCE)
}
// EncodeApprovalResponse encodes an ApprovalResponse, object tag 0x05.
//
// Field order: request_hash, responder, decision, created_at, nonce.
// request_hash comes first because it is the field that gives the object its
// meaning. The decision may be only deny (0) or allow (1); a verifier cannot
// be left with an outcome it has no rule for.
func EncodeApprovalResponse(o *ApprovalResponse) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if !o.Decision.Valid() {
return nil, fieldErr("decision", tce.ErrDecision)
}
if err := address.ValidatePubKey(o.Responder); err != nil {
return nil, fieldErr("responder", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagApprovalResponse)
e.FixedBytes("request_hash", o.RequestHash[:], tce.HashSize)
e.Identity("responder", o.Responder)
e.Uvarint(uint64(o.Decision))
e.Timestamp("created_at", o.CreatedAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxResponseTCE)
}
// EncodeAuthAssertion encodes an AuthAssertion, object tag 0x06.
//
// Field order: identity, challenge, scope, audience, created_at. The audience
// is signed so an assertion produced for one server cannot be replayed to
// another; its binding is checked by VerifyAuthAssertion.
func EncodeAuthAssertion(o *AuthAssertion) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.PubKey); err != nil {
return nil, fieldErr("identity", err)
}
if len(o.Challenge) != tce.ChallengeSize {
return nil, fieldErr("challenge", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagAuthAssertion)
e.Identity("identity", o.PubKey)
e.FixedBytes("challenge", o.Challenge, tce.ChallengeSize)
e.String("scope", o.Scope, tce.MaxScopeLen)
e.String("audience", o.Audience, tce.MaxAudienceLen)
e.Timestamp("created_at", o.CreatedAt, false)
return finishEncode(e, tce.MaxAuthTCE)
}

View file

@ -0,0 +1,89 @@
package protocol_test
import (
"bytes"
"fmt"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// ExampleClaimLifecycle is the whole client-side story for one claim, with no
// server in the loop: generate keys, build the canonical bytes, sign them, and
// let any verifier decode and verify locally. The signature is computed over
// the exact TCE bytes, so it stays valid no matter which relay later serves
// them (PROTOCOL.md section 7.3).
func Example_claimLifecycle() {
// In production use signer.Generate(); fixed seeds keep this example
// deterministic so its output can be asserted.
issuer, _ := signer.FromSeed(bytes.Repeat([]byte{0x01}, 32))
subject, _ := signer.FromSeed(bytes.Repeat([]byte{0x02}, 32))
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"example.flag": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x10}, tce.NonceSize),
}
// 1. Client builds and signs the canonical bytes.
tceBytes, err := protocol.EncodeClaim(c)
if err != nil {
fmt.Println("encode:", err)
return
}
sig := issuer.Sign(tceBytes)
// 2. Any verifier, anywhere, with only the bytes + signature.
got, err := protocol.DecodeClaim(tceBytes)
if err != nil {
fmt.Println("decode:", err)
return
}
if _, err := protocol.VerifyClaim(tceBytes, sig); err != nil {
fmt.Println("verify:", err)
return
}
v, ok := got.Claims["example.flag"].Bool()
fmt.Println("verified", got.CreatedAt == c.CreatedAt, ok && v)
// Output: verified true true
}
// ExampleAuthHandshake is the login flow (PROTOCOL.md section 8.6). The server
// mints a single-use challenge; the client proves possession of its key by
// signing an AuthAssertion bound to that challenge and to the server's
// audience. The server verifies the assertion but never sees a private key.
func Example_authHandshake() {
client, _ := signer.FromSeed(bytes.Repeat([]byte{0x03}, 32))
// Server side: issue a 32-byte challenge (CSPRNG in reality).
challenge := bytes.Repeat([]byte{0x07}, tce.ChallengeSize)
audience := "trust.n1ko.dev"
// Client side: build, sign, return.
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: challenge,
Scope: "read:claims",
Audience: audience,
CreatedAt: 1_700_000_000,
}
b, err := protocol.EncodeAuthAssertion(a)
if err != nil {
fmt.Println("encode:", err)
return
}
sig := client.Sign(b)
// Server side: verify the assertion for this exact audience.
if _, err := protocol.VerifyAuthAssertion(b, sig, audience); err != nil {
fmt.Println("auth failed:", err)
return
}
fmt.Println("auth ok")
// Output: auth ok
}

50
internal/protocol/fuzz.go Normal file
View file

@ -0,0 +1,50 @@
//go:build gofuzz
package protocol
import "bytes"
// Fuzz is the OSS-Fuzz entry point for the six protocol objects. It asserts the
// injection property of PROTOCOL.md section 12.4: any byte string that decodes
// must be exactly the canonical encoding of the object decoded from it, so a
// signature cannot be transplanted between two byte strings that denote one
// object (encode(decode(b)) == b). The decoder is total: a decode error never
// yields a usable object.
//
// Compiled only under the "gofuzz" build tag (go-fuzz / OSS-Fuzz); the
// testing.F-based targets in the package's test files are excluded there.
func Fuzz(data []byte) int {
interesting := 0
if o, err := DecodeIdentity(data); err == nil {
if out, e := EncodeIdentity(o); e == nil && bytes.Equal(out, data) {
interesting = 1
}
}
if o, err := DecodeClaim(data); err == nil {
if out, e := EncodeClaim(o); e == nil && bytes.Equal(out, data) {
interesting = 1
}
}
if o, err := DecodeRevocation(data); err == nil {
if out, e := EncodeRevocation(o); e == nil && bytes.Equal(out, data) {
interesting = 1
}
}
if o, err := DecodeApprovalRequest(data); err == nil {
if out, e := EncodeApprovalRequest(o); e == nil && bytes.Equal(out, data) {
interesting = 1
}
}
if o, err := DecodeApprovalResponse(data); err == nil {
if out, e := EncodeApprovalResponse(o); e == nil && bytes.Equal(out, data) {
interesting = 1
}
}
if o, err := DecodeAuthAssertion(data); err == nil {
if out, e := EncodeAuthAssertion(o); e == nil && bytes.Equal(out, data) {
interesting = 1
}
}
return interesting
}

View file

@ -0,0 +1,166 @@
package protocol_test
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"reflect"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// loadVectorsF is loadVectors for the seed-corpus phase of a fuzz test,
// where the failure controller is a *testing.F.
func loadVectorsF(f *testing.F) *vectorFile {
b, err := os.ReadFile(vectorsPath)
if err != nil {
f.Fatalf("read vectors: %v", err)
}
var vf vectorFile
if err := json.Unmarshal(b, &vf); err != nil {
f.Fatalf("parse vectors: %v", err)
}
return &vf
}
// FuzzDecodeIsTotalAndNonMalleable asserts the two injection properties of
// PROTOCOL.md section 12.4 against arbitrary byte strings:
//
// - the decoder never panics, hangs or returns a usable object alongside an
// error (totality);
// - any input that does decode is exactly the canonical encoding of the
// decoded object: re-encoding reproduces the input byte for byte
// (encode(decode(b)) == b). If that failed, two byte strings would denote
// one object and a signature could be transplanted.
func FuzzDecodeIsTotalAndNonMalleable(f *testing.F) {
vf := loadVectorsF(f)
for _, v := range vf.Vectors {
b, err := hex.DecodeString(v.TCEHex)
if err != nil {
f.Fatalf("corpus hex: %v", err)
}
f.Add(b)
}
f.Add([]byte(nil))
f.Add([]byte{0x74})
decode := []struct {
name string
parse func([]byte) (interface{ TCE() []byte }, error)
}{
{"identity", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeIdentity(b) }},
{"claim", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeClaim(b) }},
{"revocation", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeRevocation(b) }},
{"request", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeApprovalRequest(b) }},
{"response", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeApprovalResponse(b) }},
{"assertion", func(b []byte) (interface{ TCE() []byte }, error) { return protocol.DecodeAuthAssertion(b) }},
}
encode := map[string]func(interface{ TCE() []byte }) ([]byte, error){
"identity": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeIdentity(o.(*protocol.Identity)) },
"claim": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeClaim(o.(*protocol.Claim)) },
"revocation": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeRevocation(o.(*protocol.Revocation)) },
"request": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeApprovalRequest(o.(*protocol.ApprovalRequest)) },
"response": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeApprovalResponse(o.(*protocol.ApprovalResponse)) },
"assertion": func(o interface{ TCE() []byte }) ([]byte, error) { return protocol.EncodeAuthAssertion(o.(*protocol.AuthAssertion)) },
}
f.Fuzz(func(t *testing.T, b []byte) {
for _, d := range decode {
obj, err := d.parse(b)
if err != nil {
continue
}
if obj == nil {
t.Fatalf("nil interface from %s", d.name)
}
rv := reflect.ValueOf(obj)
if rv.Kind() == reflect.Ptr && rv.IsNil() {
t.Fatalf("boxed-nil *%s from %s", rv.Type().Elem(), d.name)
}
out, err := encode[d.name](obj)
if err != nil {
t.Fatalf("%s: encoder rejected the decoder's own object: %v", d.name, err)
}
if !eqBytes(out, b) {
t.Fatalf("%s: encode(decode(b)) != b:\n got %x\n in %x", d.name, out, b)
}
}
})
}
// FuzzClaimBuildRoundTrip builds claims of random valid shape and asserts
// decode(encode(x)) == x and object-ID stability. Encoder-determinism is the
// half of section 12.4 that starts from a structured object rather than from
// received bytes.
func FuzzClaimBuildRoundTrip(f *testing.F) {
f.Add([]byte("seed corpus"))
f.Add([]byte{})
f.Fuzz(func(t *testing.T, data []byte) {
byteAt := func(k int) byte {
if len(data) == 0 {
return 0
}
return data[k%len(data)]
}
issuerSeed := sha256.Sum256(append([]byte("issuer"), data...))
subjectSeed := sha256.Sum256(append([]byte("subject"), data...))
issuer, err := signer.FromSeed(issuerSeed[:])
if err != nil {
t.Skip("degenerate derived key")
}
subject, err := signer.FromSeed(subjectSeed[:])
if err != nil {
t.Skip("degenerate derived key")
}
claims := map[string]tce.Value{}
n := 1 + int(byteAt(0))%3
for i := 0; i < n; i++ {
key := "k" + string(rune('a'+i))
switch byteAt(i + 1) % 5 {
case 0:
claims[key] = tce.Null()
case 1:
claims[key] = tce.Bool(true)
case 2:
claims[key] = tce.Bool(false)
case 3:
claims[key] = tce.String("value-" + key)
default:
claims[key] = tce.Int(int64(i) * int64(n))
}
}
c := &protocol.Claim{
Issuer: issuer.Public(), Subject: subject.Public(),
Claims: claims, CreatedAt: 1_700_000_000, ExpiresAt: 1_700_086_400,
Serial: 1, Nonce: nonce(0x5a),
}
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatalf("encode: %v", err)
}
dec, err := protocol.DecodeClaim(b)
if err != nil {
t.Fatalf("own encoding rejected: %v", err)
}
if !eqBytes(dec.TCE(), b) {
t.Fatal("decoded claim did not retain the encoded bytes")
}
re, err := protocol.EncodeClaim(dec)
if err != nil {
t.Fatalf("re-encode: %v", err)
}
if !eqBytes(re, b) {
t.Fatalf("decode(encode(x)) != x")
}
if !tce.ComputeID(b).Equal(tce.ComputeID(re)) {
t.Fatal("object ID unstable across a decode/encode cycle")
}
})
}

View file

@ -0,0 +1,118 @@
package protocol_test
import (
"encoding/hex"
"encoding/json"
"os"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
)
const vectorsPath = "../../testdata/vectors/tce_vectors.json"
// vectorFile mirrors the frozen reference file. Only the fields the protocol
// tests need are declared.
type vectorFile struct {
Parties map[string]struct {
SeedHex string `json:"seed_hex"`
PubkeyHex string `json:"pubkey_hex"`
Address string `json:"address"`
} `json:"parties"`
Vectors []vectorEntry `json:"vectors"`
Rejects []rejectEntry `json:"rejects"`
}
type vectorEntry struct {
Name string `json:"name"`
Description string `json:"description"`
TCEHex string `json:"tce_hex"`
TCELen int `json:"tce_len"`
ObjectIDHex string `json:"object_id_hex"`
SignerPubkey string `json:"signer_pubkey_hex"`
SignerAddress string `json:"signer_address"`
SignatureHex string `json:"signature_hex"`
RequestIDHex string `json:"request_id_hex"`
JSON json.RawMessage `json:"json"`
}
type rejectEntry struct {
Name string `json:"name"`
TCEHex *string `json:"tce_hex"`
}
func loadVectors(t *testing.T) *vectorFile {
t.Helper()
b, err := os.ReadFile(vectorsPath)
if err != nil {
t.Fatalf("read vectors: %v", err)
}
var vf vectorFile
if err := json.Unmarshal(b, &vf); err != nil {
t.Fatalf("parse vectors: %v", err)
}
return &vf
}
func mustHex(t *testing.T, s string) []byte {
t.Helper()
b, err := hex.DecodeString(s)
if err != nil {
t.Fatalf("bad hex %q: %v", s, err)
}
return b
}
func mustSigner(t *testing.T) *signer.Signer {
t.Helper()
s, err := signer.Generate()
if err != nil {
t.Fatal(err)
}
return s
}
// seedSigner builds a signer from a 32-byte seed.
func seedSigner(t *testing.T, seed []byte) *signer.Signer {
t.Helper()
s, err := signer.FromSeed(seed)
if err != nil {
t.Fatalf("FromSeed: %v", err)
}
return s
}
// nonce returns a 16-byte nonce filled with a repeating byte.
func nonce(b byte) []byte {
out := make([]byte, 16)
for i := range out {
out[i] = b
}
return out
}
// signerFixtures holds two independent signers for building and signing test
// objects. Signing lives in signer, which the package under test never
// imports; tests may.
type signerFixtures struct {
alice *signer.Signer
bob *signer.Signer
}
func newSignerFixtures(t *testing.T) *signerFixtures {
t.Helper()
return &signerFixtures{alice: mustSigner(t), bob: mustSigner(t)}
}
// byName returns the vector entry with the given name, failing the test if it
// is absent.
func byName(t *testing.T, vf *vectorFile, name string) *vectorEntry {
t.Helper()
for i := range vf.Vectors {
if vf.Vectors[i].Name == name {
return &vf.Vectors[i]
}
}
t.Fatalf("vector %q not found", name)
return nil
}

View file

@ -0,0 +1,98 @@
package protocol_test
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strconv"
"testing"
)
func scanPackageProtocol(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir %s: %v", dir, err)
}
var files []string
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".go" {
continue
}
if len(e.Name()) <= 8 || e.Name()[len(e.Name())-8:] != "_test.go" {
files = append(files, filepath.Join(dir, e.Name()))
}
}
return files
}
func importsOfProtocol(t *testing.T, path string) []string {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
var out []string
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil {
t.Fatalf("bad import path in %s: %v", path, err)
}
out = append(out, p)
}
return out
}
func sourceOfProtocol(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func TestNoJSONImport(t *testing.T) {
for _, f := range scanPackageProtocol(t, ".") {
for _, imp := range importsOfProtocol(t, f) {
if imp == "encoding/json" {
t.Errorf("%s imports encoding/json; PROTOCOL.md forbids JSON in the wire codec", f)
}
}
}
}
func TestNoSigningInCodec(t *testing.T) {
re := regexp.MustCompile(`ed25519\.(Sign|NewKeyFromSeed|GenerateKey)`)
for _, f := range scanPackageProtocol(t, ".") {
if re.MatchString(sourceOfProtocol(t, f)) {
t.Errorf("%s performs ed25519 signing; signing must live only in internal/identity/signer", f)
}
}
}
func TestAllowedImports(t *testing.T) {
allowed := map[string]bool{
"bytes": true,
"crypto/ed25519": true, // Verify only; signing lives in internal/identity/signer
"crypto/subtle": true,
"errors": true,
"fmt": true,
"git.n1ko.dev/Niko/niko_trust/internal/address": true,
"git.n1ko.dev/Niko/niko_trust/internal/tce": true,
}
for _, f := range scanPackageProtocol(t, ".") {
for _, imp := range importsOfProtocol(t, f) {
switch imp {
case "go/token", "go/parser", "os", "path/filepath", "regexp", "strconv":
continue // test-only helpers
}
if !allowed[imp] {
t.Errorf("%s imports %s, which is outside the allowed protocol dependency set", f, imp)
}
}
}
}

View file

@ -0,0 +1,130 @@
package protocol_test
import (
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
)
// Mutation tests: any single-byte change to the canonical bytes or to the
// signature must break verification, and every frozen vector must still
// verify under its own signature. This is the strongest form of the
// "the bytes that arrived are the bytes that are verified" property
// (docs/IMPLEMENTATION_NOTES.md property 2): if a verifier re-serialised the
// decoded object, a mutation that survived round-tripping would pass.
// verifyFor returns an error when the given vector no longer verifies.
func verifyFor(t *testing.T, name string, reqTce, reqSig, tceB, sig []byte) error {
t.Helper()
switch name {
case "identity/nikocraft", "identity/niko":
_, err := protocol.VerifyIdentity(tceB, sig)
return err
case "claim/boolean", "claim/all-value-types":
_, err := protocol.VerifyClaim(tceB, sig)
return err
case "revocation/boolean-claim":
_, err := protocol.VerifyRevocation(tceB, sig)
return err
case "approval_request/ban":
_, err := protocol.VerifyApprovalRequest(tceB, sig)
return err
case "approval_response/allow", "approval_response/deny":
_, err := protocol.VerifyApprovalResponse(reqTce, reqSig, tceB, sig)
return err
case "auth_assertion/ws":
_, err := protocol.VerifyAuthAssertion(tceB, sig, "trust.n1ko.dev")
return err
}
return nil
}
func TestMutationSweep(t *testing.T) {
vf := loadVectors(t)
byName := map[string]*vectorEntry{}
for i := range vf.Vectors {
byName[vf.Vectors[i].Name] = &vf.Vectors[i]
}
req := byName["approval_request/ban"]
reqTce := mustHex(t, req.TCEHex)
reqSig := mustHex(t, req.SignatureHex)
for _, v := range vf.Vectors {
t.Run(v.Name, func(t *testing.T) {
b := mustHex(t, v.TCEHex)
sig := mustHex(t, v.SignatureHex)
// The unchanged vector must verify.
if err := verifyFor(t, v.Name, reqTce, reqSig, b, sig); err != nil {
t.Fatalf("baseline does not verify: %v", err)
}
// Flip every bit of the TCE bytes, one at a time.
for i := 0; i < len(b); i++ {
for _, mask := range []byte{0x01, 0x80} {
mut := append([]byte{}, b...)
mut[i] ^= mask
if err := verifyFor(t, v.Name, reqTce, reqSig, mut, sig); err == nil {
t.Fatalf("verified TCE with byte %d flipped (mask 0x%02x)", i, mask)
}
}
}
// Flip every bit of the signature, one at a time.
for i := 0; i < len(sig); i++ {
mut := append([]byte{}, sig...)
mut[i] ^= 0x01
if err := verifyFor(t, v.Name, reqTce, reqSig, b, mut); err == nil {
t.Fatalf("verified with signature byte %d flipped", i)
}
}
})
}
}
func TestDenyAndAllowDifferInOneByte(t *testing.T) {
vf := loadVectors(t)
byName := map[string]*vectorEntry{}
for i := range vf.Vectors {
byName[vf.Vectors[i].Name] = &vf.Vectors[i]
}
allow := byName["approval_response/allow"]
deny := byName["approval_response/deny"]
req := byName["approval_request/ban"]
a := mustHex(t, allow.TCEHex)
d := mustHex(t, deny.TCEHex)
if len(a) != len(d) {
t.Fatalf("allow/deny differ in length")
}
diffs := 0
for i := range a {
if a[i] != d[i] {
diffs++
}
}
// The reference design makes deny differ from allow in exactly the
// decision byte, so a verifier that ignores the decision is detectable.
if diffs != 1 {
t.Fatalf("expected the two responses to differ in one byte, got %d", diffs)
}
reqTce := mustHex(t, req.TCEHex)
reqSig := mustHex(t, req.SignatureHex)
allowSig := mustHex(t, allow.SignatureHex)
denySig := mustHex(t, deny.SignatureHex)
if _, err := protocol.VerifyApprovalResponse(reqTce, reqSig, d, denySig); err != nil {
t.Fatalf("deny does not verify: %v", err)
}
if _, err := protocol.VerifyApprovalResponse(reqTce, reqSig, a, allowSig); err != nil {
t.Fatalf("allow does not verify: %v", err)
}
// A decision cannot be transplanted across the two documents.
if _, err := protocol.VerifyApprovalResponse(reqTce, reqSig, a, denySig); err == nil {
t.Fatal("deny signature verified over allow bytes")
}
if _, err := protocol.VerifyApprovalResponse(reqTce, reqSig, d, allowSig); err == nil {
t.Fatal("allow signature verified over deny bytes")
}
}

View file

@ -0,0 +1,335 @@
// Package protocol defines the signed objects of the trust protocol and the
// rules for encoding, decoding and verifying them.
//
// Every object here is a statement made by an identity. The protocol
// establishes who made a statement and that it has not been altered. It never
// decides whether the statement should be believed or what it means: that is
// the consuming application's job (INV-5).
//
// The package can verify signatures but cannot create them. Signing lives in
// internal/identity/signer, which server-side code does not import, so a
// compromised server has no ability to forge anything (INV-1).
//
// This package does not import encoding/json. There is exactly one signing
// representation, and JSON is a transport syntax handled elsewhere
// (docs/IMPLEMENTATION_NOTES.md property 8).
package protocol
import (
"bytes"
"errors"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Errors returned when an object violates a protocol rule.
var (
// ErrNil is returned when an encoder or verifier is handed a nil object.
ErrNil = errors.New("protocol: nil object")
// ErrSignature means the Ed25519 signature did not verify over the
// received canonical bytes.
ErrSignature = errors.New("protocol: signature does not verify")
// ErrSignatureSize means the signature was not 64 bytes.
ErrSignatureSize = errors.New("protocol: signature must be 64 bytes")
// ErrWrongObject means the canonical bytes decoded to a different object
// type than the caller expected.
ErrWrongObject = errors.New("protocol: unexpected object type")
// ErrRequestMismatch means an approval response does not commit to the
// request it was presented with.
ErrRequestMismatch = errors.New("protocol: response does not match request")
// ErrWrongResponder means the response was signed by an identity other
// than the request's recipient.
ErrWrongResponder = errors.New("protocol: responder is not the request recipient")
// ErrResponseTiming means the response is dated outside the request's
// validity window.
ErrResponseTiming = errors.New("protocol: response timestamp outside request window")
// ErrWrongIssuer means a revocation was signed by someone other than the
// issuer of the claim it targets.
ErrWrongIssuer = errors.New("protocol: revocation issuer is not the claim issuer")
// ErrWrongClaim means a revocation targets a different claim.
ErrWrongClaim = errors.New("protocol: revocation does not target this claim")
// ErrAudience means an auth assertion was produced for a different
// server.
ErrAudience = errors.New("protocol: auth assertion audience mismatch")
// ErrEmptyAudience means the caller did not supply an expected audience.
ErrEmptyAudience = errors.New("protocol: expected audience must not be empty")
// ErrExpired means the object's expiry has passed.
ErrExpired = errors.New("protocol: object has expired")
// ErrNotYetValid means the object is dated too far in the future.
ErrNotYetValid = errors.New("protocol: object created too far in the future")
// ErrSelfRevocation means a revocation targets an object that is not a
// claim.
ErrSelfRevocation = errors.New("protocol: revocation target is not a claim")
)
// MaxClockSkew is the tolerance applied when comparing a signed timestamp
// with local time.
//
// Timestamps are asserted by the signer, whose clock may differ from the
// verifier's. Without an allowance, honest objects would be rejected; with too
// large an allowance, expiry becomes meaningless. See PROTOCOL.md section
// 13.1.
const MaxClockSkew = 120
// Decision is an approval outcome.
type Decision uint8
// Decision values. There are exactly two; any other encoded value is
// rejected, so a verifier cannot encounter an outcome it has no rule for.
const (
Deny Decision = tce.DecisionDeny
Allow Decision = tce.DecisionAllow
)
// String renders a decision.
func (d Decision) String() string {
switch d {
case Allow:
return "allow"
case Deny:
return "deny"
default:
return "invalid"
}
}
// Valid reports whether d is a defined decision.
func (d Decision) Valid() bool { return d == Allow || d == Deny }
// ClaimStatus is the lifecycle state of a claim as understood by the protocol
// layer.
//
// There is deliberately no "denied" or "not found" status. Absence of a claim
// is not a protocol state at all: a relay can withhold anything, so a consumer
// that treated silence as denial could be manipulated by censorship. What an
// absent claim means is a decision for the application (INV-5, and
// docs/IMPLEMENTATION_NOTES.md property 6).
type ClaimStatus uint8
// Claim statuses.
const (
// StatusActive means the claim verified and has not expired or been
// revoked.
StatusActive ClaimStatus = iota
// StatusExpired means the claim's expires_at has passed.
StatusExpired
// StatusRevoked means a verified revocation by the claim's issuer exists.
StatusRevoked
)
// String renders a status.
func (s ClaimStatus) String() string {
switch s {
case StatusActive:
return "active"
case StatusExpired:
return "expired"
case StatusRevoked:
return "revoked"
default:
return "unknown"
}
}
// Identity is a self-asserted identity registration, object tag 0x01.
//
// Registration is a convenience for discovery, not a prerequisite: an identity
// exists because its key exists.
//
// Instances returned by the decode and verify functions carry the canonical
// bytes they were read from; instances constructed in memory do not.
type Identity struct {
// PubKey is the Ed25519 public key that signed this registration.
PubKey []byte
// Alias is a self-asserted display label. It is signed here so that the
// self-assertion is tamper-evident, but it remains non-authoritative: not
// unique, not verified, and never consulted when verifying any other
// object (INV-7). This is the only object in which an alias appears.
Alias string
// CreatedAt is the signer's assertion of when this was made.
CreatedAt uint64
// tce is the canonical bytes this object was decoded from, and sig the
// signature verified over them. Both are set only by the decode and verify
// functions. The encoders ignore both and rebuild from the field list, so
// an encoding can never drift from this package's rules by accident.
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this object was decoded from, or
// nil for an object constructed in memory. Copies are returned so that a
// caller cannot alter the byte string a verifier relies on.
func (o *Identity) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *Identity) Signature() []byte { return bytes.Clone(o.sig) }
// Claim is a signed statement by an issuer about a subject, object tag 0x02.
//
// The meaning of the keys and values is entirely outside the protocol. To
// every component of the trust system they are opaque strings.
type Claim struct {
Issuer []byte
Subject []byte
// Claims holds the statements. At least one entry, at most 32.
Claims map[string]tce.Value
CreatedAt uint64
// ExpiresAt is 0 for a claim that does not expire, otherwise a timestamp
// strictly after CreatedAt.
ExpiresAt uint64
// Serial lets an issuer supersede an earlier claim about the same
// subject. It is guidance for consumers; no relay enforces an ordering.
Serial uint64
// Nonce makes otherwise identical claims distinct, so that two claims
// with the same content and timestamp have different object IDs.
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this claim was decoded from, or
// nil for a claim constructed in memory.
func (o *Claim) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *Claim) Signature() []byte { return bytes.Clone(o.sig) }
// Revocation withdraws a claim, object tag 0x03.
//
// It must be signed by the same issuer as the claim it targets. A revocation
// signed by anyone else is meaningless and is rejected.
type Revocation struct {
Issuer []byte
ClaimID tce.ID
Reason string
CreatedAt uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this revocation was decoded
// from, or nil for one constructed in memory.
func (o *Revocation) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *Revocation) Signature() []byte { return bytes.Clone(o.sig) }
// ApprovalRequest asks a recipient to approve an opaque action, tag 0x04.
type ApprovalRequest struct {
Sender []byte
Recipient []byte
// Action and Payload are opaque. Neither the relay nor this package
// assigns them meaning.
Action string
Payload map[string]tce.Value
// 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: a client
// must display the sender's address alongside it and must not present it
// as though the relay endorsed it.
Message string
CreatedAt uint64
// ExpiresAt must be after CreatedAt by at most 60 seconds. The bound is
// part of the format, so an over-long request is invalid everywhere
// rather than merely refused by one server.
ExpiresAt uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this request was decoded from,
// or nil for one constructed in memory.
func (o *ApprovalRequest) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *ApprovalRequest) Signature() []byte { return bytes.Clone(o.sig) }
// ApprovalResponse is a recipient's signed decision, object tag 0x05.
type ApprovalResponse struct {
// RequestHash is the object ID of the exact request being answered. It
// commits to the full canonical request rather than to a sender-chosen
// label, which is what makes a signed decision impossible to move to a
// different request (INV-4).
RequestHash tce.ID
Responder []byte
Decision Decision
CreatedAt uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this response was decoded from,
// or nil for one constructed in memory.
func (o *ApprovalResponse) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *ApprovalResponse) Signature() []byte { return bytes.Clone(o.sig) }
// AuthAssertion proves possession of a private key for one server-issued
// challenge, object tag 0x06.
//
// It is a transport capability only: it authenticates a connection and grants
// nothing.
type AuthAssertion struct {
PubKey []byte
Challenge []byte
Scope string
// Audience binds the assertion to one server, so that an assertion
// produced for one relay cannot be replayed to another. Claims and
// approvals carry no audience because they are global statements meant to
// be portable between relays.
Audience string
CreatedAt uint64
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this assertion was decoded from,
// or nil for one constructed in memory.
func (o *AuthAssertion) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *AuthAssertion) Signature() []byte { return bytes.Clone(o.sig) }

View file

@ -0,0 +1,110 @@
package protocol_test
import (
"errors"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// TestRejectVectors runs the frozen malformed-encoding vectors from
// PROTOCOL.md section 12.3. Every one must be refused by the strict decoder;
// a few also pin the exact reason.
func TestRejectVectors(t *testing.T) {
vf := loadVectors(t)
exact := map[string]error{
"empty": tce.ErrTruncated,
"magic_truncated": tce.ErrTruncated,
"magic_wrong_version": tce.ErrMagic,
"unknown_object_tag": tce.ErrObjectTag,
"object_tag_zero": tce.ErrObjectTag,
"non_minimal_uvarint": tce.ErrNonMinimal,
"trailing_byte": tce.ErrTrailing,
}
checked := 0
for _, r := range vf.Rejects {
if r.TCEHex == nil {
// Constructed cases (unsorted/duplicate map keys, reserved value
// tag) are exercised separately; they have no serialised bytes in
// the file.
continue
}
checked++
t.Run(r.Name, func(t *testing.T) {
b := mustHex(t, *r.TCEHex)
_, err := protocol.DecodeClaim(b)
if err == nil {
t.Fatalf("accepted a vector the reference implementation refuses")
}
if want, ok := exact[r.Name]; ok && !errors.Is(err, want) {
t.Fatalf("err = %v, want %v", err, want)
}
})
}
if checked == 0 {
t.Fatal("no vector rejects with tce_hex found")
}
}
// twoKeyClaimBytes builds a valid two-key claim, whose map occupies bytes
// [91, 98): count(1) | len(1) key value | len(1) key value.
func twoKeyClaimBytes(t *testing.T, fx *signerFixtures) []byte {
t.Helper()
c := &protocol.Claim{
Issuer: fx.alice.Public(), Subject: fx.bob.Public(),
Claims: map[string]tce.Value{"a": tce.Bool(true), "b": tce.Bool(false)},
CreatedAt: 1_700_000_000, ExpiresAt: 1_700_086_400, Serial: 1, Nonce: nonce(1),
}
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
const wantLen = 91 + 7 + 5 + 5 + 1 + 1 + 16
if len(b) != wantLen {
t.Fatalf("unexpected claim length %d, want %d; offset assumptions are stale", len(b), wantLen)
}
return b
}
func TestRejectUnsortedAndDuplicateMapKeys(t *testing.T) {
fx := newSignerFixtures(t)
base := twoKeyClaimBytes(t, fx)
t.Run("unsorted map keys", func(t *testing.T) {
bad := append([]byte{}, base...)
copy(bad[91:98], []byte{0x02, 0x01, 'b', 0x01, 0x01, 'a', 0x02})
if _, err := protocol.DecodeClaim(bad); !errors.Is(err, tce.ErrKeyOrder) {
t.Fatalf("err = %v, want ErrKeyOrder", err)
}
})
t.Run("duplicate map key", func(t *testing.T) {
bad := append([]byte{}, base...)
copy(bad[91:98], []byte{0x02, 0x01, 'a', 0x02, 0x01, 'a', 0x01})
if _, err := protocol.DecodeClaim(bad); !errors.Is(err, tce.ErrDuplicateKey) {
t.Fatalf("err = %v, want ErrDuplicateKey", err)
}
})
}
func TestRejectReservedValueTag(t *testing.T) {
// The frozen claim/boolean vector has a single value tag at offset 105.
vf := loadVectors(t)
for _, v := range vf.Vectors {
if v.Name != "claim/boolean" {
continue
}
b := mustHex(t, v.TCEHex)
for _, tag := range []byte{0x05, 0x06, 0x07, 0x08, 0xff} {
bad := append([]byte{}, b...)
bad[105] = tag
if _, err := protocol.DecodeClaim(bad); !errors.Is(err, tce.ErrValueTag) {
t.Fatalf("value tag 0x%02x: err = %v, want ErrValueTag", tag, err)
}
}
return
}
t.Fatal("claim/boolean vector not found")
}

View file

@ -0,0 +1,394 @@
package protocol_test
import (
"errors"
"fmt"
"strings"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Rule tests for the per-object constraints of PROTOCOL.md section 8. The
// primitive limits (UTF-8, uvarints, maps, numbers) are covered in
// internal/tce; these cover the object-level rules: field bounds, expiry
// relationships, the 60-second approval lifetime, the two-valued decision and
// the whole-object size limits.
func TestEncodeClaimRules(t *testing.T) {
fx := newSignerFixtures(t)
good := func() *protocol.Claim {
return &protocol.Claim{
Issuer: fx.alice.Public(), Subject: fx.bob.Public(),
Claims: map[string]tce.Value{"example.flag": tce.Bool(true)},
CreatedAt: 1_700_000_000, ExpiresAt: 1_700_086_400, Serial: 1,
Nonce: nonce(1),
}
}
t.Run("empty claims map", func(t *testing.T) {
c := good()
c.Claims = nil
if _, err := protocol.EncodeClaim(c); !errors.Is(err, tce.ErrEmptyMap) {
t.Fatalf("err = %v, want ErrEmptyMap", err)
}
})
t.Run("expires_at equal to created_at", func(t *testing.T) {
c := good()
c.ExpiresAt = c.CreatedAt
if _, err := protocol.EncodeClaim(c); !errors.Is(err, tce.ErrExpiry) {
t.Fatalf("err = %v, want ErrExpiry", err)
}
})
t.Run("expires_at before created_at", func(t *testing.T) {
c := good()
c.ExpiresAt = c.CreatedAt - 1
if _, err := protocol.EncodeClaim(c); !errors.Is(err, tce.ErrExpiry) {
t.Fatalf("err = %v, want ErrExpiry", err)
}
})
t.Run("expires_at zero is allowed", func(t *testing.T) {
c := good()
c.ExpiresAt = 0
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatalf("expires_at 0 rejected: %v", err)
}
dec, err := protocol.DecodeClaim(b)
if err != nil {
t.Fatal(err)
}
if dec.ExpiresAt != 0 {
t.Fatalf("expires_at = %d", dec.ExpiresAt)
}
})
t.Run("wrong nonce length", func(t *testing.T) {
c := good()
c.Nonce = make([]byte, 15)
if _, err := protocol.EncodeClaim(c); !errors.Is(err, tce.ErrFieldSize) {
t.Fatalf("err = %v, want ErrFieldSize", err)
}
})
t.Run("too many claim entries", func(t *testing.T) {
c := good()
m := make(map[string]tce.Value)
for i := 0; i < 33; i++ {
m[fmt.Sprintf("k%d", i)] = tce.Bool(true)
}
c.Claims = m
if _, err := protocol.EncodeClaim(c); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
t.Run("object exceeds whole limit", func(t *testing.T) {
c := good()
// 32 maximum-length string values push the claim past 4096 bytes.
m := make(map[string]tce.Value)
for i := 0; i < 32; i++ {
m[fmt.Sprintf("v%d", i)] = tce.String(strings.Repeat("x", tce.MaxStringValue))
}
c.Claims = m
if _, err := protocol.EncodeClaim(c); !errors.Is(err, tce.ErrObjectTooLarge) {
t.Fatalf("err = %v, want ErrObjectTooLarge", err)
}
})
t.Run("degenerate issuer key", func(t *testing.T) {
c := good()
c.Issuer = make([]byte, 32) // all zeros: small order, forgeable
if _, err := protocol.EncodeClaim(c); err == nil {
t.Fatal("encoded a claim over the degenerate key")
}
})
t.Run("field order matches section 8.2", func(t *testing.T) {
b, err := protocol.EncodeClaim(good())
if err != nil {
t.Fatal(err)
}
if string(b[:tce.MagicLen]) != tce.Magic {
t.Fatal("object does not start with the magic")
}
if b[tce.MagicLen] != byte(tce.TagClaim) {
t.Fatal("wrong object tag")
}
})
}
func TestEncodeApprovalRequestRules(t *testing.T) {
fx := newSignerFixtures(t)
good := func() *protocol.ApprovalRequest {
return &protocol.ApprovalRequest{
Sender: fx.alice.Public(), Recipient: fx.bob.Public(),
Action: "example.ban", Payload: map[string]tce.Value{"target": tce.String("Steve")},
Message: "Ban Steve", CreatedAt: 1_700_000_000, ExpiresAt: 1_700_000_030,
Nonce: nonce(4),
}
}
t.Run("fifty-nine second lifetime accepted", func(t *testing.T) {
r := good()
r.ExpiresAt = r.CreatedAt + 59
if _, err := protocol.EncodeApprovalRequest(r); err != nil {
t.Fatalf("lifetime 59 rejected: %v", err)
}
})
t.Run("exactly sixty seconds accepted", func(t *testing.T) {
r := good()
r.ExpiresAt = r.CreatedAt + 60
if _, err := protocol.EncodeApprovalRequest(r); err != nil {
t.Fatalf("lifetime 60 rejected: %v", err)
}
})
t.Run("sixty-one seconds rejected", func(t *testing.T) {
r := good()
r.ExpiresAt = r.CreatedAt + 61
if _, err := protocol.EncodeApprovalRequest(r); !errors.Is(err, tce.ErrLifetime) {
t.Fatalf("err = %v, want ErrLifetime", err)
}
})
t.Run("not after created_at", func(t *testing.T) {
r := good()
r.ExpiresAt = r.CreatedAt
if _, err := protocol.EncodeApprovalRequest(r); !errors.Is(err, tce.ErrExpiry) {
t.Fatalf("err = %v, want ErrExpiry", err)
}
})
t.Run("action length limit", func(t *testing.T) {
r := good()
r.Action = strings.Repeat("a", tce.MaxActionLen+1)
if _, err := protocol.EncodeApprovalRequest(r); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
t.Run("message length limit", func(t *testing.T) {
r := good()
r.Message = strings.Repeat("m", tce.MaxMessageLen+1)
if _, err := protocol.EncodeApprovalRequest(r); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
}
func TestEncodeApprovalResponseRules(t *testing.T) {
fx := newSignerFixtures(t)
good := func() *protocol.ApprovalResponse {
reqID := tce.ComputeID([]byte("request-tce-bytes-for-id"))
return &protocol.ApprovalResponse{
RequestHash: reqID, Responder: fx.bob.Public(),
Decision: protocol.Allow, CreatedAt: 1_700_000_010,
Nonce: nonce(1),
}
}
t.Run("decision must be allow or deny", func(t *testing.T) {
r := good()
r.Decision = protocol.Decision(2)
if _, err := protocol.EncodeApprovalResponse(r); !errors.Is(err, tce.ErrDecision) {
t.Fatalf("err = %v, want ErrDecision", err)
}
})
t.Run("both decisions encode", func(t *testing.T) {
for _, d := range []protocol.Decision{protocol.Deny, protocol.Allow} {
r := good()
r.Decision = d
b, err := protocol.EncodeApprovalResponse(r)
if err != nil {
t.Fatalf("decision %s: %v", d, err)
}
dec, err := protocol.DecodeApprovalResponse(b)
if err != nil {
t.Fatal(err)
}
if dec.Decision != d {
t.Fatalf("decision round trip %s -> %s", d, dec.Decision)
}
}
})
}
func TestEncodeIdentityRules(t *testing.T) {
fx := newSignerFixtures(t)
good := func() *protocol.Identity {
return &protocol.Identity{PubKey: fx.alice.Public(), Alias: "alice", CreatedAt: 1_700_000_000}
}
t.Run("alias length limit", func(t *testing.T) {
i := good()
i.Alias = strings.Repeat("a", tce.MaxAliasLen+1)
if _, err := protocol.EncodeIdentity(i); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
t.Run("empty alias allowed", func(t *testing.T) {
i := good()
i.Alias = ""
b, err := protocol.EncodeIdentity(i)
if err != nil {
t.Fatalf("empty alias rejected: %v", err)
}
// The empty alias encodes as the single byte 0x00.
dec, err := protocol.DecodeIdentity(b)
if err != nil {
t.Fatal(err)
}
if dec.Alias != "" {
t.Fatalf("alias round trip = %q", dec.Alias)
}
})
t.Run("degenerate key rejected", func(t *testing.T) {
i := good()
i.PubKey = make([]byte, 32)
if _, err := protocol.EncodeIdentity(i); err == nil {
t.Fatal("registered the degenerate key")
}
})
}
func TestEncodeAuthAssertionRules(t *testing.T) {
fx := newSignerFixtures(t)
challenge := make([]byte, 32)
for i := range challenge {
challenge[i] = byte(i)
}
good := func() *protocol.AuthAssertion {
return &protocol.AuthAssertion{
PubKey: fx.bob.Public(), Challenge: challenge,
Scope: "ws", Audience: "trust.n1ko.dev", CreatedAt: 1_700_000_000,
}
}
t.Run("challenge length must be 32", func(t *testing.T) {
a := good()
a.Challenge = make([]byte, 31)
if _, err := protocol.EncodeAuthAssertion(a); !errors.Is(err, tce.ErrFieldSize) {
t.Fatalf("err = %v, want ErrFieldSize", err)
}
})
t.Run("scope length limit", func(t *testing.T) {
a := good()
a.Scope = strings.Repeat("s", tce.MaxScopeLen+1)
if _, err := protocol.EncodeAuthAssertion(a); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
t.Run("audience length limit", func(t *testing.T) {
a := good()
a.Audience = strings.Repeat("a", tce.MaxAudienceLen+1)
if _, err := protocol.EncodeAuthAssertion(a); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
}
func TestEncodeRevocationRules(t *testing.T) {
fx := newSignerFixtures(t)
good := func() *protocol.Revocation {
return &protocol.Revocation{
Issuer: fx.alice.Public(), ClaimID: tce.ComputeID([]byte("claim")),
Reason: "superseded", CreatedAt: 1_700_000_100, Nonce: nonce(3),
}
}
t.Run("reason length limit", func(t *testing.T) {
r := good()
r.Reason = strings.Repeat("r", tce.MaxReasonLen+1)
if _, err := protocol.EncodeRevocation(r); !errors.Is(err, tce.ErrTooLong) {
t.Fatalf("err = %v, want ErrTooLong", err)
}
})
t.Run("empty reason allowed", func(t *testing.T) {
r := good()
r.Reason = ""
b, err := protocol.EncodeRevocation(r)
if err != nil {
t.Fatalf("empty reason rejected: %v", err)
}
if _, err := protocol.DecodeRevocation(b); err != nil {
t.Fatal(err)
}
})
}
// rawClaimViaTCE writes a claim directly with the primitive encoder, bypassing
// the protocol layer's object rules, so the decoder-side enforcement can be
// exercised on malformed-but-well-typed bytes.
func rawClaimViaTCE(t *testing.T, issuer, subject []byte, createdAt, expiresAt, serial uint64) []byte {
t.Helper()
e := tce.NewEncoder()
e.Header(tce.TagClaim)
e.Identity("issuer", issuer)
e.Identity("subject", subject)
e.Map("claims", map[string]tce.Value{"k": tce.Bool(true)}, 1)
e.Timestamp("created_at", createdAt, false)
e.Timestamp("expires_at", expiresAt, true)
e.Uvarint(serial)
e.FixedBytes("nonce", nonce(1), tce.NonceSize)
b, err := e.Bytes()
if err != nil {
t.Fatal(err)
}
return b
}
func TestDecodeClaimEnforcesObjectRules(t *testing.T) {
fx := newSignerFixtures(t)
t.Run("expires_at must be after created_at", func(t *testing.T) {
ts := uint64(1_700_000_000)
b := rawClaimViaTCE(t, fx.alice.Public(), fx.bob.Public(), ts, ts, 1)
if _, err := protocol.DecodeClaim(b); !errors.Is(err, tce.ErrExpiry) {
t.Fatalf("err = %v, want ErrExpiry", err)
}
})
t.Run("empty claims map rejected", func(t *testing.T) {
e := tce.NewEncoder()
e.Header(tce.TagClaim)
e.Identity("issuer", fx.alice.Public())
e.Identity("subject", fx.bob.Public())
e.Map("claims", nil, 1) // ErrEmptyMap fires at encode
// Encode will already have failed; confirm the decoder path by
// writing an empty map directly.
b, err := rawEmptyClaimsClaim(t, fx)
if err != nil {
t.Fatal(err)
}
if _, err := protocol.DecodeClaim(b); !errors.Is(err, tce.ErrEmptyMap) {
t.Fatalf("err = %v, want ErrEmptyMap", err)
}
})
t.Run("whole-object limit", func(t *testing.T) {
// A 4097-byte input pretends to be a claim; it must be refused on
// size alone before any parsing.
b := make([]byte, tce.MaxClaimTCE+1)
b[0] = 0x74
if _, err := protocol.DecodeClaim(b); !errors.Is(err, tce.ErrObjectTooLarge) {
t.Fatalf("err = %v, want ErrObjectTooLarge", err)
}
})
}
func rawEmptyClaimsClaim(t *testing.T, fx *signerFixtures) ([]byte, error) {
t.Helper()
// Manually encode a claim with an empty map (count 0x00), which the
// protocol claims map forbids.
d := tce.NewEncoder()
d.Header(tce.TagClaim)
d.Identity("issuer", fx.alice.Public())
d.Identity("subject", fx.bob.Public())
d.Uvarint(0)
d.Timestamp("created_at", 1_700_000_000, false)
d.Timestamp("expires_at", 1_700_086_400, true)
d.Uvarint(1)
d.FixedBytes("nonce", nonce(1), tce.NonceSize)
return d.Bytes()
}

View file

@ -0,0 +1,238 @@
package protocol_test
import (
"encoding/hex"
"encoding/json"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Golden tests against the frozen reference vectors.
//
// These are the heart of cross-implementation agreement: the Go code must
// reproduce every byte of the Python reference implementation's output, must
// verify every reference signature, and must assign every object the same
// SHA-256 object ID. Any disagreement here means the Go implementation is
// wrong, not the vectors (docs/PROTOCOL.md section 14).
type vectorJSON struct {
Type string `json:"type"`
}
// jsonString and jsonUint pull fields out of the JSON view for comparison.
func jsonString(t *testing.T, raw json.RawMessage, key string) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("json view: %v", err)
}
s, _ := m[key].(string)
return s
}
func jsonNonce(t *testing.T, raw json.RawMessage) string {
return jsonString(t, raw, "nonce")
}
// reencodeAndCompare decodes one object, encodes its public fields back, and
// requires the result to equal the received bytes byte for byte. This is the
// cross-implementation identity: the encoder and the Python reference must
// agree, and the decoder must have read exactly the reference's field order.
func reencodeAndCompare(t *testing.T, label string, got []byte, want []byte) {
t.Helper()
if !eqBytes(got, want) {
t.Fatalf("%s: encoder did not reproduce the reference bytes\n got %x\nwant %x", label, got, want)
}
}
func eqBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestVectorPartiesDerivedFromSeeds(t *testing.T) {
vf := loadVectors(t)
if len(vf.Parties) != 2 {
t.Fatalf("expect exactly two parties, got %d", len(vf.Parties))
}
for name, p := range vf.Parties {
seed := mustHex(t, p.SeedHex)
s := seedSigner(t, seed)
if hex.EncodeToString(s.Public()) != p.PubkeyHex {
t.Errorf("%s: public key does not match the reference", name)
}
if s.Address().String() != p.Address {
t.Errorf("%s: address does not match the reference", name)
}
}
}
func TestVectorGolden(t *testing.T) {
vf := loadVectors(t)
if len(vf.Vectors) != 9 {
t.Fatalf("expect 9 object vectors, got %d", len(vf.Vectors))
}
byName := map[string]vectorEntry{}
for _, v := range vf.Vectors {
byName[v.Name] = v
}
claimBool := byName["claim/boolean"]
req := byName["approval_request/ban"]
var revClaim *protocol.Claim
var rev *protocol.Revocation
for _, v := range vf.Vectors {
t.Run(v.Name, func(t *testing.T) {
tceBytes := mustHex(t, v.TCEHex)
if len(tceBytes) != v.TCELen {
t.Fatalf("tce_len: field says %d, hex has %d", v.TCELen, len(tceBytes))
}
sig := mustHex(t, v.SignatureHex)
if got, want := hex.EncodeToString(tce.ComputeID(tceBytes).Bytes()), v.ObjectIDHex; got != want {
t.Fatalf("object ID mismatch\n got %s\nwant %s", got, want)
}
var typ vectorJSON
if err := json.Unmarshal(v.JSON, &typ); err != nil {
t.Fatal(err)
}
switch typ.Type {
case "identity":
obj, err := protocol.VerifyIdentity(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyIdentity: %v", err)
}
reencodeAndCompare(t, "identity", mustTCE(t, obj), tceBytes)
if obj.Alias != jsonString(t, v.JSON, "alias") {
t.Errorf("alias decoded as %q", obj.Alias)
}
if obj.CreatedAt != 1_700_000_000 {
t.Errorf("created_at = %d", obj.CreatedAt)
}
checkAddress(t, obj.PubKey, v.SignerAddress)
case "claim":
obj, err := protocol.VerifyClaim(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyClaim: %v", err)
}
reencodeAndCompare(t, "claim", mustTCE(t, obj), tceBytes)
if len(obj.Claims) < 1 || len(obj.Claims) > 32 {
t.Errorf("claims size %d out of bounds", len(obj.Claims))
}
checkAddress(t, obj.Issuer, v.SignerAddress)
if hex.EncodeToString(obj.Nonce) != jsonNonce(t, v.JSON) {
t.Errorf("nonce mismatch")
}
// The two response vectors and the revocation reference this
// claim by ID; capture the verified claim for later checks.
if v.Name == "claim/boolean" {
revClaim = obj
}
case "revocation":
obj, err := protocol.VerifyRevocation(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyRevocation: %v", err)
}
reencodeAndCompare(t, "revocation", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.Issuer, v.SignerAddress)
rev = obj
// The frozen revocation withdraws claim/boolean by ID; verify
// the binding passes against the decoded claim.
if claimBool.ObjectIDHex != "" && obj.ClaimID.String() != claimBool.ObjectIDHex {
t.Errorf("revocation targets %s, want the boolean claim", obj.ClaimID)
}
case "approval_request":
obj, err := protocol.VerifyApprovalRequest(tceBytes, sig)
if err != nil {
t.Fatalf("VerifyApprovalRequest: %v", err)
}
reencodeAndCompare(t, "approval_request", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.Sender, v.SignerAddress)
if obj.ExpiresAt-obj.CreatedAt > 60 {
t.Errorf("request lifetime %d exceeds the format bound", obj.ExpiresAt-obj.CreatedAt)
}
case "approval_response":
reqBytes := mustHex(t, req.TCEHex)
reqSig := mustHex(t, req.SignatureHex)
obj, err := protocol.VerifyApprovalResponse(reqBytes, reqSig, tceBytes, sig)
if err != nil {
t.Fatalf("VerifyApprovalResponse: %v", err)
}
reencodeAndCompare(t, "approval_response", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.Responder, v.SignerAddress)
if jsonString(t, v.JSON, "decision") != obj.Decision.String() {
t.Errorf("decision field mismatch")
}
// request_hash is the object ID of the exact request.
if obj.RequestHash.String() != req.ObjectIDHex {
t.Errorf("request_hash does not match the request object ID")
}
case "auth_assertion":
obj, err := protocol.VerifyAuthAssertion(tceBytes, sig, "trust.n1ko.dev")
if err != nil {
t.Fatalf("VerifyAuthAssertion: %v", err)
}
reencodeAndCompare(t, "auth_assertion", mustTCE(t, obj), tceBytes)
checkAddress(t, obj.PubKey, v.SignerAddress)
if jsonString(t, v.JSON, "challenge") != hex.EncodeToString(obj.Challenge) {
t.Errorf("challenge mismatch")
}
default:
t.Fatalf("unknown json type %q", typ.Type)
}
})
}
// Cross-object bindings that only become checkable once every vector is
// verified.
t.Run("revocation withdraws the boolean claim", func(t *testing.T) {
if revClaim == nil || rev == nil {
t.Fatal("precondition: claim/boolean and revocation not both decoded")
}
if err := protocol.VerifyRevocationOf(rev, revClaim); err != nil {
t.Fatalf("VerifyRevocationOf: %v", err)
}
})
}
// mustTCE returns the verified object's canonical bytes.
func mustTCE(t *testing.T, obj interface{ TCE() []byte }) []byte {
t.Helper()
return obj.TCE()
}
// checkAddress asserts that the decoded key renders as the reference address.
func checkAddress(t *testing.T, pub []byte, wantAddr string) {
t.Helper()
a, err := address.FromPubKey(pub)
if err != nil {
t.Fatalf("decoded key is not a valid address: %v", err)
}
if a.String() != wantAddr {
t.Fatalf("address %s, want %s", a.String(), wantAddr)
}
}
// TestVectorSignatureMutation is the signature half of the cross-check: a
// signature that verifies over the unchanged bytes must fail over any
// mutation of the TCE bytes (see mutation_test.go for the byte-by-byte
// sweep).

295
internal/protocol/verify.go Normal file
View file

@ -0,0 +1,295 @@
package protocol
import (
"crypto/ed25519"
"crypto/subtle"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Verification of the six protocol objects, following the order of
// PROTOCOL.md section 7.3: a decoded object is an object whose canonical
// bytes were read strictly and whose public keys are valid curve points, and
// only then is the Ed25519 signature checked over those exact bytes.
//
// Nothing here re-encodes a decoded object and verifies the result. The
// verified bytes are the received bytes, retained by the decode step
// (docs/IMPLEMENTATION_NOTES.md property 2).
//
// Scope of this package: it establishes who signed a statement and that the
// statement is unchanged and well-formed. Whether the statement should be
// believed is the caller's policy (INV-5), and object-vs-now timing is a
// separate, explicit step (ValidateCurrent) so that verification stays
// deterministic.
// verifySignature checks an Ed25519 signature over the exact canonical bytes
// the decoded object was read from.
//
// crypto/ed25519.Verify performs no key validation, which is why the decode
// path already validated every public key through address.ValidatePubKey.
func verifySignature(pub, tceBytes, sig []byte) error {
if len(sig) != tce.SignatureSize {
return ErrSignatureSize
}
if !ed25519.Verify(ed25519.PublicKey(pub), tceBytes, sig) {
return ErrSignature
}
return nil
}
// VerifyIdentity decodes the canonical bytes, checks the signature under the
// registration's public key and returns the registered identity.
func VerifyIdentity(tceBytes, sig []byte) (*Identity, error) {
o, err := DecodeIdentity(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.PubKey, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyClaim decodes the canonical bytes, checks the signature under the
// issuer's public key and returns the claim.
func VerifyClaim(tceBytes, sig []byte) (*Claim, error) {
o, err := DecodeClaim(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.Issuer, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyRevocation decodes the canonical bytes and checks the signature under
// the issuer's public key. Whether the revocation actually withdraws a
// particular claim is a separate check, VerifyRevocationOf.
func VerifyRevocation(tceBytes, sig []byte) (*Revocation, error) {
o, err := DecodeRevocation(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.Issuer, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyApprovalRequest decodes the canonical bytes and checks the signature
// under the sender's public key.
func VerifyApprovalRequest(tceBytes, sig []byte) (*ApprovalRequest, error) {
o, err := DecodeApprovalRequest(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.Sender, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyAuthAssertion decodes the canonical bytes, checks the signature under
// the asserting identity's public key, and requires the signed audience to
// equal expectedAudience exactly (INV-4).
//
// There is no default audience, no empty-means-any case and no substring or
// suffix matching: an assertion produced for one server must never
// authenticate a connection to another. An empty expectedAudience is an
// error.
func VerifyAuthAssertion(tceBytes, sig []byte, expectedAudience string) (*AuthAssertion, error) {
if expectedAudience == "" {
return nil, ErrEmptyAudience
}
o, err := DecodeAuthAssertion(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.PubKey, o.tce, sig); err != nil {
return nil, err
}
// Constant-time comparison so that the byte length difference revealed by
// a length check is the only thing an observer learns.
if subtle.ConstantTimeCompare([]byte(o.Audience), []byte(expectedAudience)) != 1 {
return nil, ErrAudience
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyApprovalResponse verifies an approval response against the exact
// request it claims to answer.
//
// Both the request and the response must verify. There is deliberately no
// function that verifies a response on its own, because a response is only
// meaningful relative to the request it commits to (INV-3): this is the
// request_hash binding that makes a signed decision impossible to move to a
// different request.
//
// The checks, on top of both signatures, are those of PROTOCOL.md section
// 8.5: SHA-256(received_request_tce) equals response.request_hash; the
// responder equals the request's recipient; and the response is dated within
// the request's validity window with the clock-skew allowance.
//
// Replay state — the one-response-per-request rule — is kept by the caller,
// because it cannot be reproduced from two byte strings alone and is a
// storage concern.
func VerifyApprovalResponse(requestTCE, requestSig, responseTCE, responseSig []byte) (*ApprovalResponse, error) {
req, err := VerifyApprovalRequest(requestTCE, requestSig)
if err != nil {
return nil, err
}
resp, err := DecodeApprovalResponse(responseTCE)
if err != nil {
return nil, err
}
if err := verifySignature(resp.Responder, resp.tce, responseSig); err != nil {
return nil, err
}
// 1. The response commits to the exact request bytes, compared in
// constant time with no parsing involved.
reqID := tce.ComputeID(req.tce)
if !resp.RequestHash.Equal(reqID) {
return nil, ErrRequestMismatch
}
// 2. Only the recipient of the request may answer it.
if subtle.ConstantTimeCompare(resp.Responder, req.Recipient) != 1 {
return nil, ErrWrongResponder
}
// 3. The response must fall inside the request's window. Both timestamps
// come from different signers with independent clocks, so the clock-skew
// allowance of section 13.1 applies to each bound.
if err := checkResponseWindow(req, resp); err != nil {
return nil, err
}
resp.sig = make([]byte, len(responseSig))
copy(resp.sig, responseSig)
return resp, nil
}
// VerifyApprovalResponseStandalone decodes the canonical bytes and checks the
// signature under the responder's public key, without binding to a request.
// Use VerifyApprovalResponse when the request is available; this is for the
// transport layer and other contexts that only hold the response.
func VerifyApprovalResponseStandalone(tceBytes, sig []byte) (*ApprovalResponse, error) {
o, err := DecodeApprovalResponse(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.Responder, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// VerifyAuthAssertionSignature decodes the canonical bytes and checks the
// signature under the asserting identity's public key, without checking the
// audience. Use VerifyAuthAssertion when the expected audience is known; this
// is for the transport layer which has no server context.
func VerifyAuthAssertionSignature(tceBytes, sig []byte) (*AuthAssertion, error) {
o, err := DecodeAuthAssertion(tceBytes)
if err != nil {
return nil, err
}
if err := verifySignature(o.PubKey, o.tce, sig); err != nil {
return nil, err
}
o.sig = make([]byte, len(sig))
copy(o.sig, sig)
return o, nil
}
// checkResponseWindow enforces
// request.created_at - skew <= response.created_at <= request.expires_at + skew.
func checkResponseWindow(req *ApprovalRequest, resp *ApprovalResponse) error {
if req.CreatedAt > resp.CreatedAt {
if req.CreatedAt-resp.CreatedAt > MaxClockSkew {
return ErrResponseTiming
}
} else if resp.CreatedAt > req.ExpiresAt {
if resp.CreatedAt-req.ExpiresAt > MaxClockSkew {
return ErrResponseTiming
}
}
return nil
}
// VerifyRevocationOf checks that a verified revocation withdraws a verified
// claim: it must target the claim's object ID and be signed by the claim's
// issuer. A revocation signed by anyone else is meaningless and is rejected
// (PROTOCOL.md section 8.3).
//
// Both objects must have been verified first, since both the issuer tie and
// the content hash are only trustworthy once the signatures hold.
func VerifyRevocationOf(rev *Revocation, claim *Claim) error {
if rev == nil || claim == nil {
return ErrNil
}
want := tce.ComputeID(claim.tce)
if subtle.ConstantTimeCompare(rev.ClaimID[:], want[:]) != 1 {
return ErrWrongClaim
}
if subtle.ConstantTimeCompare(rev.Issuer, claim.Issuer) != 1 {
return ErrWrongIssuer
}
return nil
}
// ValidateCurrent reports whether an object dated createdAt with optional
// expiry expiresAt is valid at time now, applying the clock-skew allowance of
// PROTOCOL.md section 13.1.
//
// A timestamp cannot be trusted absolutely and there is no ordering between
// different signers' clocks, so an object whose created_at is no more than the
// allowance in the future is accepted, and an object accepted no more than the
// allowance after its expiry is still current. Anything further away is an
// error: ErrNotYetValid for the future, ErrExpired for the past.
func ValidateCurrent(createdAt, expiresAt, now uint64) error {
if createdAt > now && createdAt-now > MaxClockSkew {
return ErrNotYetValid
}
if expiresAt != 0 && now > expiresAt && now-expiresAt > MaxClockSkew {
return ErrExpired
}
return nil
}
// ClaimStatusAt returns the current validity of a claim at time now, without
// consulting any revocation store.
//
// There are exactly three outcomes and there is deliberately no
// "not found" status: absence of a claim is not a protocol state, because a
// relay can withhold anything and a consumer that treated silence as a
// negative answer could be manipulated by censorship
// (docs/IMPLEMENTATION_NOTES.md property 6). An application decides what an
// absent claim means.
//
// Revoked is not returned here precisely because the caller holds the
// revocation store: whether a verified revocation exists is up to the
// application, which finds StatusRevoked by combining this with its own
// evidence.
func ClaimStatusAt(o *Claim, now uint64) ClaimStatus {
if o == nil {
return StatusActive
}
if o.ExpiresAt != 0 && now > o.ExpiresAt && now-o.ExpiresAt > MaxClockSkew {
return StatusExpired
}
return StatusActive
}

View file

@ -0,0 +1,368 @@
package protocol_test
import (
"errors"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Verification tests for PROTOCOL.md sections 7.3 and 8. The signature order
// is exercised in mutation_test.go byte by byte; here the object-specific
// rules are pinned: request_hash binding, responder identity, response
// timing, audience binding and revocation/claim binding.
const t0 = uint64(1_700_000_000)
func signedClaim(t *testing.T, fx *signerFixtures) ([]byte, []byte, *protocol.Claim) {
t.Helper()
c := &protocol.Claim{
Issuer: fx.alice.Public(), Subject: fx.bob.Public(),
Claims: map[string]tce.Value{"example.flag": tce.Bool(true)},
CreatedAt: t0, ExpiresAt: t0 + 86400, Serial: 1, Nonce: nonce(1),
}
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
return b, fx.alice.Sign(b), c
}
func signedRequest(t *testing.T, fx *signerFixtures) ([]byte, []byte) {
t.Helper()
r := &protocol.ApprovalRequest{
Sender: fx.alice.Public(), Recipient: fx.bob.Public(),
Action: "example.ban", Payload: map[string]tce.Value{},
Message: "peer", CreatedAt: t0, ExpiresAt: t0 + 30, Nonce: nonce(4),
}
b, err := protocol.EncodeApprovalRequest(r)
if err != nil {
t.Fatal(err)
}
return b, fx.alice.Sign(b)
}
func signedResponse(t *testing.T, fx *signerFixtures, requestHash tce.ID, createdAt uint64) ([]byte, []byte) {
t.Helper()
r := &protocol.ApprovalResponse{
RequestHash: requestHash, Responder: fx.bob.Public(),
Decision: protocol.Allow, CreatedAt: createdAt, Nonce: nonce(1),
}
b, err := protocol.EncodeApprovalResponse(r)
if err != nil {
t.Fatal(err)
}
return b, fx.bob.Sign(b)
}
func TestVerifyClaimOK(t *testing.T) {
fx := newSignerFixtures(t)
b, sig, _ := signedClaim(t, fx)
obj, err := protocol.VerifyClaim(b, sig)
if err != nil {
t.Fatalf("VerifyClaim: %v", err)
}
if obj.Issuer[0] != fx.alice.Public()[0] {
t.Fatal("issuer mismatched")
}
if obj.Signature() == nil || len(obj.Signature()) != tce.SignatureSize {
t.Fatal("verified claim did not retain its signature")
}
if !eqBytes(obj.TCE(), b) {
t.Fatal("verified claim did not retain the received bytes")
}
}
func TestVerifyClaimRejectsBadSignature(t *testing.T) {
fx := newSignerFixtures(t)
b, sig, _ := signedClaim(t, fx)
t.Run("foreign signature", func(t *testing.T) {
carol := mustSigner(t)
if _, err := protocol.VerifyClaim(b, carol.Sign(b)); !errors.Is(err, protocol.ErrSignature) {
t.Fatalf("err = %v, want ErrSignature", err)
}
})
t.Run("short signature", func(t *testing.T) {
if _, err := protocol.VerifyClaim(b, sig[:len(sig)-1]); !errors.Is(err, protocol.ErrSignatureSize) {
t.Fatalf("err = %v, want ErrSignatureSize", err)
}
})
t.Run("empty signature", func(t *testing.T) {
if _, err := protocol.VerifyClaim(b, nil); !errors.Is(err, protocol.ErrSignatureSize) {
t.Fatalf("err = %v, want ErrSignatureSize", err)
}
})
t.Run("tampered bytes", func(t *testing.T) {
bad := append([]byte{}, b...)
bad[len(bad)/2] ^= 0x01
if _, err := protocol.VerifyClaim(bad, sig); err == nil {
t.Fatal("verified a tampered claim")
}
})
t.Run("wrong object type refuses", func(t *testing.T) {
// An identity vector's bytes are not a claim.
id, err := protocol.EncodeIdentity(&protocol.Identity{PubKey: fx.alice.Public(), Alias: "", CreatedAt: t0})
if err != nil {
t.Fatal(err)
}
if _, err := protocol.VerifyClaim(id, fx.alice.Sign(id)); !errors.Is(err, protocol.ErrWrongObject) {
t.Fatalf("err = %v, want ErrWrongObject", err)
}
})
}
func TestVerifyApprovalResponseRules(t *testing.T) {
fx := newSignerFixtures(t)
reqT, reqSig := signedRequest(t, fx)
reqID := tce.ComputeID(reqT)
respT, respSig := signedResponse(t, fx, reqID, t0+10)
t.Run("positive path", func(t *testing.T) {
resp, err := protocol.VerifyApprovalResponse(reqT, reqSig, respT, respSig)
if err != nil {
t.Fatalf("VerifyApprovalResponse: %v", err)
}
if resp.Decision != protocol.Allow {
t.Fatalf("decision = %s", resp.Decision)
}
})
t.Run("request_hash must bind the exact bytes", func(t *testing.T) {
// A different request with otherwise-plausible content.
otherReq := &protocol.ApprovalRequest{
Sender: fx.alice.Public(), Recipient: fx.bob.Public(),
Action: "example.ban", Payload: map[string]tce.Value{},
Message: "please", CreatedAt: t0, ExpiresAt: t0 + 30, Nonce: nonce(5),
}
otherT, err := protocol.EncodeApprovalRequest(otherReq)
if err != nil {
t.Fatal(err)
}
otherSig := fx.alice.Sign(otherT)
if _, err := protocol.VerifyApprovalResponse(otherT, otherSig, respT, respSig); !errors.Is(err, protocol.ErrRequestMismatch) {
t.Fatalf("err = %v, want ErrRequestMismatch", err)
}
})
t.Run("responder must equal recipient", func(t *testing.T) {
carol := mustSigner(t)
r := &protocol.ApprovalResponse{
RequestHash: reqID, Responder: carol.Public(),
Decision: protocol.Allow, CreatedAt: t0 + 10, Nonce: nonce(1),
}
b, err := protocol.EncodeApprovalResponse(r)
if err != nil {
t.Fatal(err)
}
if _, err := protocol.VerifyApprovalResponse(reqT, reqSig, b, carol.Sign(b)); !errors.Is(err, protocol.ErrWrongResponder) {
t.Fatalf("err = %v, want ErrWrongResponder", err)
}
})
t.Run("response dated before the request falls outside the window", func(t *testing.T) {
tooEarly := t0 - protocol.MaxClockSkew - 1
b, s := signedResponse(t, fx, reqID, tooEarly)
if _, err := protocol.VerifyApprovalResponse(reqT, reqSig, b, s); !errors.Is(err, protocol.ErrResponseTiming) {
t.Fatalf("err = %v, want ErrResponseTiming", err)
}
})
t.Run("response dated after expiry falls outside the window", func(t *testing.T) {
tooLate := (t0 + 30) + protocol.MaxClockSkew + 1
b, s := signedResponse(t, fx, reqID, tooLate)
if _, err := protocol.VerifyApprovalResponse(reqT, reqSig, b, s); !errors.Is(err, protocol.ErrResponseTiming) {
t.Fatalf("err = %v, want ErrResponseTiming", err)
}
})
t.Run("response inside the window at both extremes", func(t *testing.T) {
for _, at := range []uint64{t0 - protocol.MaxClockSkew, t0 + 30 + protocol.MaxClockSkew} {
b, s := signedResponse(t, fx, reqID, at)
if _, err := protocol.VerifyApprovalResponse(reqT, reqSig, b, s); err != nil {
t.Fatalf("response at %d rejected: %v", at, err)
}
}
})
t.Run("invalid request signature poisons the response", func(t *testing.T) {
carol := mustSigner(t)
// Present the request signed by someone other than its sender.
if _, err := protocol.VerifyApprovalResponse(reqT, carol.Sign(reqT), respT, respSig); err == nil {
t.Fatal("verified a response over a request that does not verify")
}
})
}
func TestVerifyAuthAssertionAudience(t *testing.T) {
fx := newSignerFixtures(t)
challenge := make([]byte, 32)
for i := range challenge {
challenge[i] = byte(i)
}
a := &protocol.AuthAssertion{
PubKey: fx.bob.Public(), Challenge: challenge,
Scope: "ws", Audience: "trust.n1ko.dev", CreatedAt: t0,
}
b, err := protocol.EncodeAuthAssertion(a)
if err != nil {
t.Fatal(err)
}
sig := fx.bob.Sign(b)
t.Run("exact audience accepted", func(t *testing.T) {
if _, err := protocol.VerifyAuthAssertion(b, sig, "trust.n1ko.dev"); err != nil {
t.Fatalf("exact audience rejected: %v", err)
}
})
t.Run("empty expected audience is an error", func(t *testing.T) {
if _, err := protocol.VerifyAuthAssertion(b, sig, ""); !errors.Is(err, protocol.ErrEmptyAudience) {
t.Fatalf("err = %v, want ErrEmptyAudience", err)
}
})
for _, want := range []string{
"other.example", "trust.n1ko.dev.", "trust.n1ko.dev-evil", ".trust.n1ko.dev", "TRUST.N1KO.DEV",
} {
t.Run("mismatched/prefix/suffix audience "+want, func(t *testing.T) {
if _, err := protocol.VerifyAuthAssertion(b, sig, want); !errors.Is(err, protocol.ErrAudience) {
t.Fatalf("err = %v, want ErrAudience", err)
}
})
}
t.Run("assertion for another server cannot authenticate here", func(t *testing.T) {
other := &protocol.AuthAssertion{
PubKey: fx.bob.Public(), Challenge: challenge,
Scope: "ws", Audience: "another.example", CreatedAt: t0,
}
otherB, err := protocol.EncodeAuthAssertion(other)
if err != nil {
t.Fatal(err)
}
if _, err := protocol.VerifyAuthAssertion(otherB, fx.bob.Sign(otherB), "trust.n1ko.dev"); !errors.Is(err, protocol.ErrAudience) {
t.Fatalf("err = %v, want ErrAudience", err)
}
})
}
func TestVerifyRevocationBinding(t *testing.T) {
fx := newSignerFixtures(t)
claimT, claimSig, _ := signedClaim(t, fx)
claim, err := protocol.VerifyClaim(claimT, claimSig)
if err != nil {
t.Fatal(err)
}
signAndDecode := func(rev *protocol.Revocation, s *signer.Signer) (*protocol.Revocation, []byte, []byte) {
b, err := protocol.EncodeRevocation(rev)
if err != nil {
t.Fatal(err)
}
sig := s.Sign(b)
ver, err := protocol.VerifyRevocation(b, sig)
if err != nil {
t.Fatal(err)
}
return ver, b, sig
}
t.Run("issuer withdraws own claim", func(t *testing.T) {
rev := &protocol.Revocation{
Issuer: fx.alice.Public(), ClaimID: tce.ComputeID(claimT),
Reason: "superseded", CreatedAt: t0 + 100, Nonce: nonce(3),
}
ver, _, _ := signAndDecode(rev, fx.alice)
if err := protocol.VerifyRevocationOf(ver, claim); err != nil {
t.Fatalf("VerifyRevocationOf: %v", err)
}
})
t.Run("revocation by a foreign issuer is meaningless", func(t *testing.T) {
carol := mustSigner(t)
rev := &protocol.Revocation{
Issuer: carol.Public(), ClaimID: tce.ComputeID(claimT),
Reason: "superseded", CreatedAt: t0 + 100, Nonce: nonce(3),
}
ver, _, _ := signAndDecode(rev, carol)
if err := protocol.VerifyRevocationOf(ver, claim); !errors.Is(err, protocol.ErrWrongIssuer) {
t.Fatalf("err = %v, want ErrWrongIssuer", err)
}
})
t.Run("revocation of a different claim", func(t *testing.T) {
rev := &protocol.Revocation{
Issuer: fx.alice.Public(), ClaimID: tce.ComputeID([]byte("some other object")),
Reason: "superseded", CreatedAt: t0 + 100, Nonce: nonce(3),
}
ver, _, _ := signAndDecode(rev, fx.alice)
if err := protocol.VerifyRevocationOf(ver, claim); !errors.Is(err, protocol.ErrWrongClaim) {
t.Fatalf("err = %v, want ErrWrongClaim", err)
}
})
}
func TestValidateCurrent(t *testing.T) {
const created = uint64(1_700_000_000)
cases := []struct {
name string
now uint64
expires uint64
wantErr error
}{
{"now equals created", created, 0, nil},
{"within future allowance", created - protocol.MaxClockSkew, 0, nil},
{"exactly at future allowance", created - protocol.MaxClockSkew, 0, nil},
{"just beyond future allowance", created - protocol.MaxClockSkew - 1, 0, protocol.ErrNotYetValid},
{"created in the past is fine", created + 1000, 0, nil},
{"non-expiring stays valid", created + 1_000_000, 0, nil},
{"now within expiry allowance", created, created + 86400, nil},
{"exactly at expiry allowance", created + 86400 + protocol.MaxClockSkew, created + 86400, nil},
{"just beyond expiry allowance", created + 86400 + protocol.MaxClockSkew + 1, created + 86400, protocol.ErrExpired},
{"expires_at zero means never expired", created + 1_000_000_000, 0, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := protocol.ValidateCurrent(created, c.expires, c.now)
if c.wantErr == nil {
if err != nil {
t.Fatalf("ValidateCurrent = %v, want nil", err)
}
return
}
if !errors.Is(err, c.wantErr) {
t.Fatalf("ValidateCurrent = %v, want %v", err, c.wantErr)
}
})
}
}
func TestClaimStatusAt(t *testing.T) {
fx := newSignerFixtures(t)
active := &protocol.Claim{
Issuer: fx.alice.Public(), Subject: fx.bob.Public(),
Claims: map[string]tce.Value{"k": tce.Bool(true)},
CreatedAt: t0, ExpiresAt: t0 + 86400, Serial: 1, Nonce: nonce(1),
}
never := &protocol.Claim{
Issuer: fx.alice.Public(), Subject: fx.bob.Public(),
Claims: map[string]tce.Value{"k": tce.Bool(true)},
CreatedAt: t0, ExpiresAt: 0, Serial: 1, Nonce: nonce(2),
}
if s := protocol.ClaimStatusAt(active, t0); s != protocol.StatusActive {
t.Fatalf("status at created = %s, want active", s)
}
if s := protocol.ClaimStatusAt(active, t0+86400+protocol.MaxClockSkew); s != protocol.StatusActive {
t.Fatalf("status inside expiry allowance = %s, want active", s)
}
if s := protocol.ClaimStatusAt(active, t0+86400+protocol.MaxClockSkew+1); s != protocol.StatusExpired {
t.Fatalf("status past expiry = %s, want expired", s)
}
// A non-expiring claim never reports expired, no matter how large the
// clock value.
if s := protocol.ClaimStatusAt(never, uint64(1<<40)); s != protocol.StatusActive {
t.Fatalf("non-expiring claim status = %s", s)
}
}

View file

@ -0,0 +1,42 @@
package server_test
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"strconv"
"testing"
)
// TestServerDoesNotImportSigner enforces INV-1 structurally: the relay never
// links the client-only signing package, so a server compromise cannot forge.
func TestServerDoesNotImportSigner(t *testing.T) {
dir := "."
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".go" {
continue
}
if e.Name()[len(e.Name())-8:] == "_test.go" {
continue
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, e.Name(), nil, parser.ImportsOnly)
if err != nil {
t.Fatal(err)
}
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil {
continue
}
if p == "git.n1ko.dev/Niko/niko_trust/internal/identity/signer" {
t.Errorf("%s imports the signer package, violating INV-1", e.Name())
}
}
}
}

View file

@ -0,0 +1,42 @@
package server
import (
"fmt"
"io"
"sync/atomic"
)
// Metrics holds the relay's in-process counters, exposed at /metrics in the
// Prometheus text exposition format. No external dependency: the values are
// plain atomic counters rendered as text.
type Metrics struct {
objectsStored atomic.Uint64
objectsRejected atomic.Uint64
objectsDeleted atomic.Uint64
challengesIssued atomic.Uint64
assertionsOK atomic.Uint64
assertionsFailed atomic.Uint64
requestsTotal atomic.Uint64
sessionsActive atomic.Int64
}
// inc records an event.
func (m *Metrics) inc(f *atomic.Uint64) { f.Add(1) }
// Write renders the counters in Prometheus text format.
func (m *Metrics) Write(w io.Writer) {
fmt.Fprintf(w, "# TYPE trust_objects_stored counter\n")
fmt.Fprintf(w, "trust_objects_stored %d\n", m.objectsStored.Load())
fmt.Fprintf(w, "# TYPE trust_objects_rejected counter\n")
fmt.Fprintf(w, "trust_objects_rejected %d\n", m.objectsRejected.Load())
fmt.Fprintf(w, "# TYPE trust_challenges_issued counter\n")
fmt.Fprintf(w, "trust_challenges_issued %d\n", m.challengesIssued.Load())
fmt.Fprintf(w, "# TYPE trust_assertions_ok counter\n")
fmt.Fprintf(w, "trust_assertions_ok %d\n", m.assertionsOK.Load())
fmt.Fprintf(w, "# TYPE trust_assertions_failed counter\n")
fmt.Fprintf(w, "trust_assertions_failed %d\n", m.assertionsFailed.Load())
fmt.Fprintf(w, "# TYPE trust_requests_total counter\n")
fmt.Fprintf(w, "trust_requests_total %d\n", m.requestsTotal.Load())
fmt.Fprintf(w, "# TYPE trust_sessions_active gauge\n")
fmt.Fprintf(w, "trust_sessions_active %d\n", m.sessionsActive.Load())
}

View file

@ -0,0 +1,60 @@
package server
import (
"net"
"net/http"
"sync"
"time"
)
// ipLimiter is a fixed-window per-client rate limiter keyed by remote IP. It is
// deliberately minimal: no bursting, no warm-up, just a hard cap per window to
// keep an open relay from being flooded.
type ipLimiter struct {
mu sync.Mutex
limit int
window time.Duration
hits map[string]int
reset map[string]time.Time
}
func newIPLimiter(limit int, window time.Duration) *ipLimiter {
return &ipLimiter{
limit: limit,
window: window,
hits: make(map[string]int),
reset: make(map[string]time.Time),
}
}
// allow reports whether the client may proceed, incrementing its windowed count.
func (l *ipLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
until, ok := l.reset[ip]
if !ok || now.After(until) {
l.hits[ip] = 0
l.reset[ip] = now.Add(l.window)
} else if now.Sub(until) > l.window*2 {
// Lazy cleanup of long-idle entries.
delete(l.hits, ip)
delete(l.reset, ip)
l.hits[ip] = 0
l.reset[ip] = now.Add(l.window)
}
if l.hits[ip] >= l.limit {
return false
}
l.hits[ip]++
return true
}
// clientIP returns the request's remote IP, stripping a port if present.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

View file

@ -0,0 +1,60 @@
package server
import (
"net/http"
"testing"
"time"
)
func TestAllowUnderLimit(t *testing.T) {
l := newIPLimiter(3, time.Minute)
for i := 0; i < 3; i++ {
if !l.allow("1.2.3.4") {
t.Fatalf("request %d: expected allow", i)
}
}
if l.allow("1.2.3.4") {
t.Fatal("expected deny once limit reached")
}
}
func TestDistinctIPsIndependent(t *testing.T) {
l := newIPLimiter(1, time.Minute)
if !l.allow("10.0.0.1") {
t.Fatal("ip1 first request should be allowed")
}
if l.allow("10.0.0.1") {
t.Fatal("ip1 second request should be denied")
}
if !l.allow("10.0.0.2") {
t.Fatal("ip2 should have its own budget")
}
}
func TestWindowReset(t *testing.T) {
l := newIPLimiter(2, 20*time.Millisecond)
if !l.allow("9.9.9.9") || !l.allow("9.9.9.9") {
t.Fatal("first two requests should be allowed")
}
if l.allow("9.9.9.9") {
t.Fatal("third request within window should be denied")
}
time.Sleep(30 * time.Millisecond)
if !l.allow("9.9.9.9") {
t.Fatal("request after window expiry should be allowed again")
}
}
func TestClientIP(t *testing.T) {
withPort, _ := http.NewRequest(http.MethodGet, "/", nil)
withPort.RemoteAddr = "192.168.1.5:54321"
if got := clientIP(withPort); got != "192.168.1.5" {
t.Fatalf("expected 192.168.1.5, got %q", got)
}
noPort, _ := http.NewRequest(http.MethodGet, "/", nil)
noPort.RemoteAddr = "10.0.0.1"
if got := clientIP(noPort); got != "10.0.0.1" {
t.Fatalf("expected 10.0.0.1, got %q", got)
}
}

434
internal/server/server.go Normal file
View file

@ -0,0 +1,434 @@
package server
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
// maxBodyBytes bounds the size of any request body the relay will read. A
// content-addressed object is bounded by protocol limits; this is a hard cap on
// the wire envelope so a single client cannot exhaust server memory.
const maxBodyBytes = tce.MaxClaimTCE*2 + 1024
// challengeTTL is how long an issued auth challenge stays valid.
const challengeTTL = 5 * time.Minute
// sessionTTL is how long a verified auth session stays valid.
const sessionTTL = 30 * time.Minute
// Server is the trust relay: it stores signed objects and brokers
// authentication, without ever holding a signing key or making authorization
// decisions (INV-1, INV-5).
type Server struct {
store *Store
audience string
metrics *Metrics
ready bool
putLimiter *ipLimiter
challengeLimiter *ipLimiter
mu sync.Mutex
challenges map[string]time.Time // challenge hex -> expiry
sessions map[string]session // session token -> session
}
type session struct {
identity string
scope string
expiry time.Time
}
// New builds a relay that binds AuthAssertions to the given audience (its
// hostname, e.g. "trust.n1ko.dev"). If dataDir is non-empty, objects are
// persisted there across restarts.
func New(audience, dataDir string) *Server {
s := &Server{
store: NewStore(dataDir),
audience: audience,
metrics: &Metrics{},
challenges: make(map[string]time.Time),
sessions: make(map[string]session),
putLimiter: newIPLimiter(60, time.Minute),
challengeLimiter: newIPLimiter(30, time.Minute),
}
s.ready = true
return s
}
// Store exposes the underlying object store.
func (s *Server) Store() *Store { return s.store }
// Handler returns the HTTP handler implementing the JSON transport.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/objects", s.rateLimitPut)
mux.HandleFunc("GET /v1/objects/{id}", s.handleGet)
mux.HandleFunc("GET /v1/claims", s.handleClaims)
mux.HandleFunc("GET /v1/requests", s.handleRequests)
mux.HandleFunc("GET /v1/responses", s.handleResponses)
mux.HandleFunc("GET /v1/revocations", s.handleRevocations)
mux.HandleFunc("GET /v1/config", s.handleConfig)
mux.HandleFunc("GET /v1/metrics", s.handleMetrics)
mux.HandleFunc("GET /v1/healthz", s.handleHealth)
mux.HandleFunc("GET /v1/readyz", s.handleReady)
mux.HandleFunc("POST /v1/auth/challenge", s.rateLimitChallenge)
mux.HandleFunc("POST /v1/auth/assert", s.handleAssert)
return mux
}
func (s *Server) rateLimitPut(w http.ResponseWriter, r *http.Request) {
if !s.putLimiter.allow(clientIP(r)) {
writeErr(w, http.StatusTooManyRequests, "rate limited")
return
}
s.HandlePut(w, r)
}
func (s *Server) rateLimitChallenge(w http.ResponseWriter, r *http.Request) {
if !s.challengeLimiter.allow(clientIP(r)) {
writeErr(w, http.StatusTooManyRequests, "rate limited")
return
}
s.handleChallenge(w, r)
}
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
s.metrics.Write(w)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
if s.ready {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
return
}
writeErr(w, http.StatusServiceUnavailable, "not ready")
}
func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
env, ok := decodeEnvelope(w, r)
if !ok {
return
}
if len(env.TCE) == 0 {
writeErr(w, http.StatusBadRequest, "missing tce")
return
}
id, err := s.store.Put(env.TCE, env.Signature, env.ObjectID)
if err != nil {
s.metrics.inc(&s.metrics.objectsRejected)
writeErr(w, http.StatusUnprocessableEntity, err.Error())
return
}
s.metrics.inc(&s.metrics.objectsStored)
writeJSON(w, http.StatusOK, map[string]string{"object_id": id})
}
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
env, err := s.store.Get(id)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, env)
}
// scopeOK reports whether a session may perform an action requiring the given
// scope. Besides the exact scope, a session granted the generic "read" or "*"
// capability may read any read endpoint.
func scopeOK(ses session, required string) bool {
return ses.scope == required || ses.scope == "read" || ses.scope == "*"
}
// paginate applies optional limit/offset query params to a result list. A
// non-positive limit means "no cap".
func paginate(in []*transport.Envelope, limit, offset int) []*transport.Envelope {
if offset > 0 {
if offset >= len(in) {
return nil
}
in = in[offset:]
}
if limit > 0 && limit < len(in) {
in = in[:limit]
}
return in
}
// listParams reads limit/offset from the query string.
func listParams(r *http.Request) (limit, offset int) {
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
limit = n
}
}
if v := r.URL.Query().Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
offset = n
}
}
return
}
func (s *Server) handleClaims(w http.ResponseWriter, r *http.Request) {
ses, ok := s.sessionByToken(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "authentication required")
return
}
if !scopeOK(ses, "read:claims") {
writeErr(w, http.StatusForbidden, "insufficient scope")
return
}
subject := r.URL.Query().Get("subject")
if subject == "" {
writeErr(w, http.StatusBadRequest, "missing subject")
return
}
list, err := s.store.ClaimsBySubject(subject)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
lim, off := listParams(r)
writeJSON(w, http.StatusOK, map[string]any{"claims": paginate(list, lim, off)})
}
func (s *Server) handleRequests(w http.ResponseWriter, r *http.Request) {
ses, ok := s.sessionByToken(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "authentication required")
return
}
if !scopeOK(ses, "read:requests") {
writeErr(w, http.StatusForbidden, "insufficient scope")
return
}
recipient := r.URL.Query().Get("recipient")
if recipient == "" {
writeErr(w, http.StatusBadRequest, "missing recipient")
return
}
list, err := s.store.RequestsByRecipient(recipient)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
lim, off := listParams(r)
writeJSON(w, http.StatusOK, map[string]any{"requests": paginate(list, lim, off)})
}
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
ses, ok := s.sessionByToken(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "authentication required")
return
}
if !scopeOK(ses, "read:responses") {
writeErr(w, http.StatusForbidden, "insufficient scope")
return
}
req := r.URL.Query().Get("request")
if req == "" {
writeErr(w, http.StatusBadRequest, "missing request")
return
}
list, err := s.store.ResponsesForRequest(req)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
lim, off := listParams(r)
writeJSON(w, http.StatusOK, map[string]any{"responses": paginate(list, lim, off)})
}
func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) {
ses, ok := s.sessionByToken(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "authentication required")
return
}
if !scopeOK(ses, "read:revocations") {
writeErr(w, http.StatusForbidden, "insufficient scope")
return
}
claim := r.URL.Query().Get("claim")
if claim == "" {
writeErr(w, http.StatusBadRequest, "missing claim")
return
}
list, err := s.store.RevocationsForClaim(claim)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
lim, off := listParams(r)
writeJSON(w, http.StatusOK, map[string]any{"revocations": paginate(list, lim, off)})
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"audience": s.audience})
}
func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
ch := make([]byte, tce.ChallengeSize)
if _, err := rand.Read(ch); err != nil {
writeErr(w, http.StatusInternalServerError, "challenge")
return
}
chHex := hex.EncodeToString(ch)
s.mu.Lock()
// Drop any challenges that have lapsed without being used.
nowTs := now()
for k, exp := range s.challenges {
if exp.Before(nowTs) {
delete(s.challenges, k)
}
}
s.challenges[chHex] = nowTs.Add(challengeTTL)
s.mu.Unlock()
s.metrics.inc(&s.metrics.challengesIssued)
writeJSON(w, http.StatusOK, map[string]string{"challenge": chHex})
}
func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) {
env, ok := decodeEnvelope(w, r)
if !ok {
return
}
// Strict-decode and verify the assertion, bound to this server's audience.
if _, _, err := transport.DecodeObject(env.TCE); err != nil {
writeErr(w, http.StatusUnprocessableEntity, "rejected: "+err.Error())
return
}
assert, err := protocol.DecodeAuthAssertion(env.TCE)
if err != nil {
writeErr(w, http.StatusUnprocessableEntity, "rejected")
return
}
if _, err := protocol.VerifyAuthAssertion(env.TCE, env.Signature, s.audience); err != nil {
writeErr(w, http.StatusUnauthorized, "auth failed: "+err.Error())
return
}
// The assertion must be bound to a challenge this server actually issued,
// and each challenge is single-use: consuming it here blocks replay of a
// captured assertion.
chHex := hex.EncodeToString(assert.Challenge)
s.mu.Lock()
expiry, issued := s.challenges[chHex]
if issued {
delete(s.challenges, chHex)
}
s.mu.Unlock()
if !issued || expiry.Before(now()) {
s.metrics.inc(&s.metrics.assertionsFailed)
writeErr(w, http.StatusUnauthorized, "unknown or expired challenge")
return
}
// The assertion is valid for this audience; mint an opaque session token.
// The server holds no signing key (INV-1): the token is a random reference
// to the verified identity, stored server-side.
tok := make([]byte, 32)
if _, err := rand.Read(tok); err != nil {
writeErr(w, http.StatusInternalServerError, "session")
return
}
tokHex := hex.EncodeToString(tok)
id := identityFromPubKey(assert.PubKey)
s.mu.Lock()
s.sessions[tokHex] = session{identity: id, scope: assert.Scope, expiry: now().Add(sessionTTL)}
s.metrics.sessionsActive.Add(1)
s.mu.Unlock()
s.metrics.inc(&s.metrics.assertionsOK)
writeJSON(w, http.StatusOK, map[string]string{
"session_token": tokHex,
"identity": id,
"scope": assert.Scope,
})
}
// sessionByToken returns a valid, unexpired session for the request's bearer
// token, if any. Expired sessions are purged on access.
func (s *Server) sessionByToken(r *http.Request) (session, bool) {
tok := bearerToken(r)
if tok == "" {
return session{}, false
}
s.mu.Lock()
ses, ok := s.sessions[tok]
if ok {
if ses.expiry.Before(now()) {
delete(s.sessions, tok)
s.metrics.sessionsActive.Add(-1)
ok = false
}
}
s.mu.Unlock()
return ses, ok
}
// bearerToken extracts a session token from the Authorization header or the
// ?token= query parameter.
func bearerToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(h[len("Bearer "):])
}
return r.URL.Query().Get("token")
}
func identityFromPubKey(pub []byte) string {
id, err := identity.FromPubKey(pub)
if err != nil {
return ""
}
return id.Address().String()
}
// decodeEnvelope reads a JSON envelope from the request, enforcing the
// server-wide body cap first.
func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var env transport.Envelope
if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
writeErr(w, http.StatusRequestEntityTooLarge, "payload too large")
} else {
writeErr(w, http.StatusBadRequest, "bad envelope")
}
return nil, false
}
return &env, true
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}

View file

@ -0,0 +1,884 @@
package server_test
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/server"
"git.n1ko.dev/Niko/niko_trust/internal/verify"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
srv := server.New("trust.n1ko.dev", "")
return httptest.NewServer(srv.Handler())
}
func postEnvelope(t *testing.T, url string, env *transport.Envelope) *http.Response {
t.Helper()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(env); err != nil {
t.Fatal(err)
}
resp, err := http.Post(url, "application/json", &buf)
if err != nil {
t.Fatal(err)
}
return resp
}
// authToken performs the challenge/assert handshake and returns the session
// token, for use by tests that need an authenticated read.
func authToken(t *testing.T, baseURL string, key *signer.Signer) string {
t.Helper()
cr, err := http.Post(baseURL+"/v1/auth/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
var chRes struct{ Challenge string `json:"challenge"` }
_ = json.NewDecoder(cr.Body).Decode(&chRes)
cr.Body.Close()
ch, err := hex.DecodeString(chRes.Challenge)
if err != nil {
t.Fatal(err)
}
a := &protocol.AuthAssertion{
PubKey: key.Public(),
Challenge: ch,
Scope: "read",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, err := protocol.EncodeAuthAssertion(a)
if err != nil {
t.Fatal(err)
}
sig := key.Sign(tceBytes)
resp := postEnvelope(t, baseURL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusOK {
t.Fatalf("assert status %d", resp.StatusCode)
}
var out struct {
SessionToken string `json:"session_token"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
if out.SessionToken == "" {
t.Fatal("no session token")
}
return out.SessionToken
}
func TestPublishAndGetClaim(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"flag.example": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x01}, tce.NonceSize),
}
tceBytes, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
sig := issuer.Sign(tceBytes)
// Publish.
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusOK {
t.Fatalf("publish status %d", resp.StatusCode)
}
var pub struct {
ObjectID string `json:"object_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&pub)
resp.Body.Close()
if pub.ObjectID == "" {
t.Fatal("no object_id returned")
}
// The returned ID must equal SHA-256(tce).
if pub.ObjectID != tce.ComputeID(tceBytes).String() {
t.Fatal("object_id mismatch")
}
// Fetch it back.
got, err := http.Get(ts.URL + "/v1/objects/" + pub.ObjectID)
if err != nil {
t.Fatal(err)
}
defer got.Body.Close()
if got.StatusCode != http.StatusOK {
t.Fatalf("get status %d", got.StatusCode)
}
var env transport.Envelope
if err := json.NewDecoder(got.Body).Decode(&env); err != nil {
t.Fatal(err)
}
// The verifier checks the signature over the exact TCE bytes.
if typ, err := env.Verify(); err != nil {
t.Fatalf("verify %s: %v", typ, err)
}
// Query by subject. The read endpoint now requires an authenticated
// session; authenticate as the subject itself with scope read:claims.
tok := authToken(t, ts.URL, subject)
q, err := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String(), nil)
if err != nil {
t.Fatal(err)
}
q.Header.Set("Authorization", "Bearer "+tok)
qresp, err := http.DefaultClient.Do(q)
if err != nil {
t.Fatal(err)
}
defer qresp.Body.Close()
if qresp.StatusCode != http.StatusOK {
t.Fatalf("claims status %d", qresp.StatusCode)
}
var list struct {
Claims []json.RawMessage `json:"claims"`
}
if err := json.NewDecoder(qresp.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if len(list.Claims) != 1 {
t.Fatalf("expected 1 claim, got %d", len(list.Claims))
}
}
func TestAuthHandshake(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
// Challenge.
cr, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
var chRes struct{ Challenge string `json:"challenge"` }
_ = json.NewDecoder(cr.Body).Decode(&chRes)
cr.Body.Close()
ch, err := hex.DecodeString(chRes.Challenge)
if err != nil {
t.Fatal(err)
}
// Assertion.
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: ch,
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
b, err := protocol.EncodeAuthAssertion(a)
if err != nil {
t.Fatal(err)
}
sig := client.Sign(b)
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: b, Signature: sig})
if resp.StatusCode != http.StatusOK {
t.Fatalf("assert status %d", resp.StatusCode)
}
var out struct {
SessionToken string `json:"session_token"`
Identity string `json:"identity"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
if out.SessionToken == "" {
t.Fatal("no session token")
}
if out.Identity != client.Address().String() {
t.Fatalf("identity mismatch: %s != %s", out.Identity, client.Address().String())
}
}
func TestRejectsBadObjectID(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
}
tceBytes, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(tceBytes)
// Lie about the object_id.
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{
TCE: tceBytes, Signature: sig, ObjectID: "deadbeef",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestGetMissingReturns404(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/v1/objects/" + hex.EncodeToString(bytes.Repeat([]byte{0}, 32)))
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestDuplicatePutIsIdempotent(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x03}, tce.NonceSize),
}
tceBytes, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
for i := 0; i < 2; i++ {
resp := postEnvelope(t, ts.URL+"/v1/objects", env)
if resp.StatusCode != http.StatusOK {
t.Fatalf("put %d: expected 200, got %d", i, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestAssertRejectsBadSignature(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
claimer, _ := signer.Generate()
impostor, _ := signer.Generate()
a := &protocol.AuthAssertion{
PubKey: claimer.Public(),
Challenge: bytes.Repeat([]byte{0x07}, tce.ChallengeSize),
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
// Sign with the wrong key.
sig := impostor.Sign(tceBytes)
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestAssertRejectsUnissuedChallenge(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
// Assertion bound to a challenge the server never issued.
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: bytes.Repeat([]byte{0x09}, tce.ChallengeSize),
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
sig := client.Sign(tceBytes)
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for unissued challenge, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestAssertChallengeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
chRes := struct{ Challenge string `json:"challenge"` }{}
resp, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
_ = json.NewDecoder(resp.Body).Decode(&chRes)
resp.Body.Close()
ch, _ := hex.DecodeString(chRes.Challenge)
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: ch,
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
sig := client.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
// First use succeeds.
r1 := postEnvelope(t, ts.URL+"/v1/auth/assert", env)
if r1.StatusCode != http.StatusOK {
t.Fatalf("first assert: expected 200, got %d", r1.StatusCode)
}
r1.Body.Close()
// Replaying the same challenge (captured assertion) is rejected.
r2 := postEnvelope(t, ts.URL+"/v1/auth/assert", env)
if r2.StatusCode != http.StatusUnauthorized {
t.Fatalf("replay: expected 401, got %d", r2.StatusCode)
}
r2.Body.Close()
}
func TestClaimsRequiresAuth(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/v1/claims?subject=" + hex.EncodeToString(bytes.Repeat([]byte{0}, 32)))
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestClaimsInsufficientScope(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
// Authenticate with a scope that does not cover read:claims.
cr, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
var chRes struct{ Challenge string `json:"challenge"` }
_ = json.NewDecoder(cr.Body).Decode(&chRes)
cr.Body.Close()
ch, _ := hex.DecodeString(chRes.Challenge)
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: ch,
Scope: "read:requests",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
sig := client.Sign(tceBytes)
ar := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
var out struct{ SessionToken string `json:"session_token"` }
_ = json.NewDecoder(ar.Body).Decode(&out)
ar.Body.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject=x", nil)
req.Header.Set("Authorization", "Bearer "+out.SessionToken)
resp, _ := http.DefaultClient.Do(req)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestStorePersistsAcrossRestart(t *testing.T) {
dir := t.TempDir()
// Start one server, publish an object.
s1 := server.New("trust.n1ko.dev", dir)
issuer, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x04}, tce.NonceSize),
}
tceBytes, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(tceBytes)
id, err := s1.Store().Put(tceBytes, sig, "")
if err != nil {
t.Fatal(err)
}
// A fresh server over the same directory must recover the object.
s2 := server.New("trust.n1ko.dev", dir)
env, err := s2.Store().Get(id)
if err != nil {
t.Fatalf("object not recovered after restart: %v", err)
}
if _, err := env.Verify(); err != nil {
t.Fatalf("recovered object failed verify: %v", err)
}
if got, _ := s2.Store().ClaimsBySubject(issuer.Address().String()); len(got) != 1 {
t.Fatalf("expected 1 recovered claim, got %d", len(got))
}
}
func TestConfigReportsAudience(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/v1/config")
if resp.StatusCode != http.StatusOK {
t.Fatalf("config status %d", resp.StatusCode)
}
var cfg struct{ Audience string `json:"audience"` }
_ = json.NewDecoder(resp.Body).Decode(&cfg)
resp.Body.Close()
if cfg.Audience != "trust.n1ko.dev" {
t.Fatalf("audience = %q", cfg.Audience)
}
}
func TestReadEndpointsRequireAuth(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
for _, ep := range []string{
"/v1/claims?subject=x",
"/v1/requests?recipient=x",
"/v1/responses?request=x",
"/v1/revocations?claim=x",
} {
resp, _ := http.Get(ts.URL + ep)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s: expected 401, got %d", ep, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestApprovalFlow(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
Message: "approve",
CreatedAt: now,
ExpiresAt: now + 30,
Nonce: bytes.Repeat([]byte{0x11}, tce.NonceSize),
}
reqTCE, _ := protocol.EncodeApprovalRequest(req)
reqID := tce.ComputeID(reqTCE).String()
if _, err := sPut(ts, reqTCE, issuer.Sign(reqTCE)); err != nil {
t.Fatal(err)
}
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqTCE),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: now + 5,
Nonce: bytes.Repeat([]byte{0x22}, tce.NonceSize),
}
respTCE, _ := protocol.EncodeApprovalResponse(resp)
if _, err := sPut(ts, respTCE, approver.Sign(respTCE)); err != nil {
t.Fatal(err)
}
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x33}, tce.NonceSize),
}
claimTCE, _ := protocol.EncodeClaim(c)
claimID, err := sPut(ts, claimTCE, issuer.Sign(claimTCE))
if err != nil {
t.Fatal(err)
}
rv := &protocol.Revocation{
Issuer: issuer.Public(),
ClaimID: tce.ComputeID(claimTCE),
Reason: "bad",
CreatedAt: now,
Nonce: bytes.Repeat([]byte{0x44}, tce.NonceSize),
}
rvTCE, _ := protocol.EncodeRevocation(rv)
if _, err := sPut(ts, rvTCE, issuer.Sign(rvTCE)); err != nil {
t.Fatal(err)
}
// Authenticate with a generic read token.
tok := authToken(t, ts.URL, subject)
check := func(ep string, want int) []json.RawMessage {
r, _ := http.NewRequest(http.MethodGet, ts.URL+ep, nil)
r.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(r)
if resp.StatusCode != want {
t.Fatalf("%s: expected %d, got %d", ep, want, resp.StatusCode)
}
var wrap struct {
Requests []json.RawMessage `json:"requests"`
Responses []json.RawMessage `json:"responses"`
Revocations []json.RawMessage `json:"revocations"`
}
_ = json.NewDecoder(resp.Body).Decode(&wrap)
resp.Body.Close()
switch {
case wrap.Requests != nil:
return wrap.Requests
case wrap.Responses != nil:
return wrap.Responses
case wrap.Revocations != nil:
return wrap.Revocations
}
return nil
}
if got := check("/v1/requests?recipient="+approver.Address().String(), http.StatusOK); len(got) != 1 {
t.Fatalf("requests: expected 1, got %d", len(got))
}
if got := check("/v1/responses?request="+reqID, http.StatusOK); len(got) != 1 {
t.Fatalf("responses: expected 1, got %d", len(got))
}
if got := check("/v1/revocations?claim="+claimID, http.StatusOK); len(got) != 1 {
t.Fatalf("revocations: expected 1, got %d", len(got))
}
}
// sPut publishes a raw envelope to the relay.
func sPut(ts *httptest.Server, tceBytes, sig []byte) (string, error) {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(&transport.Envelope{TCE: tceBytes, Signature: sig})
resp, err := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("put status %d", resp.StatusCode)
}
var out struct{ ObjectID string `json:"object_id"` }
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
return out.ObjectID, nil
}
func TestPutStoresUnverifiedEnvelope(t *testing.T) {
// The relay is a dumb store: it accepts any well-formed envelope regardless
// of signature validity. Signature checking is deferred to the verifier
// (verify.Graph.Add), which is what decides "should I believe it".
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(cb)
// Tamper with the signature so verification must fail downstream.
bad := make([]byte, len(sig))
copy(bad, sig)
bad[len(bad)-1] ^= 0xff
env := &transport.Envelope{TCE: cb, Signature: bad}
resp := postEnvelope(t, ts.URL+"/v1/objects", env)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("relay should store unverified envelope, got %d", resp.StatusCode)
}
// The verifier must reject the tampered envelope.
if err := verify.NewGraph().Add(env); err == nil {
t.Fatal("expected verify.Graph.Add to reject tampered signature")
}
}
func TestIdempotentPut(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(cb)
id1, err := sPut(ts, cb, sig)
if err != nil {
t.Fatal(err)
}
id2, err := sPut(ts, cb, sig)
if err != nil {
t.Fatal(err)
}
if id1 != id2 {
t.Fatalf("expected identical object id on replay, got %q and %q", id1, id2)
}
// A replayed claim must not create a duplicate: only one claim for subject.
tok := authToken(t, ts.URL, subject)
r, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String(), nil)
r.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(r)
var list struct {
Claims []json.RawMessage `json:"claims"`
}
_ = json.NewDecoder(resp.Body).Decode(&list)
resp.Body.Close()
if len(list.Claims) != 1 {
t.Fatalf("expected exactly 1 stored claim after replay, got %d", len(list.Claims))
}
}
func TestHealthMetricsEndpoints(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
for _, ep := range []string{"/v1/healthz", "/v1/readyz"} {
resp, _ := http.Get(ts.URL + ep)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: expected 200, got %d", ep, resp.StatusCode)
}
resp.Body.Close()
}
resp, _ := http.Get(ts.URL + "/v1/metrics")
if resp.StatusCode != http.StatusOK {
t.Fatalf("metrics: expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), "trust_objects_stored") {
t.Fatalf("metrics missing objects_stored: %s", body)
}
}
func TestPagination(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
// Publish three distinct claims about the same subject.
for i := 0; i < 3; i++ {
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Number(fmt.Sprintf("%d", i))},
CreatedAt: now - 10,
Serial: uint64(i + 1),
Nonce: bytes.Repeat([]byte{byte(i + 1)}, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
if _, err := sPut(ts, cb, issuer.Sign(cb)); err != nil {
t.Fatal(err)
}
}
tok := authToken(t, ts.URL, subject)
r, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String()+"&limit=1&offset=1", nil)
r.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(r)
var list struct {
Claims []json.RawMessage `json:"claims"`
}
_ = json.NewDecoder(resp.Body).Decode(&list)
resp.Body.Close()
if len(list.Claims) != 1 {
t.Fatalf("expected 1 paginated claim, got %d", len(list.Claims))
}
}
func TestPerSubjectQuota(t *testing.T) {
// Exercise handlePut directly (bypassing the HTTP rate limiter) so we can
// publish enough distinct claims to reach the per-subject quota.
srv := server.New("trust.n1ko.dev", "")
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
const n = 1000
for i := 0; i < n; i++ {
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Number(fmt.Sprintf("%d", i))},
CreatedAt: now - 10,
Serial: uint64(i + 1),
Nonce: bytes.Repeat([]byte{byte((i + 1) & 0xff)}, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
env := &transport.Envelope{TCE: cb, Signature: issuer.Sign(cb)}
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/v1/objects", &buf)
srv.HandlePut(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("claim %d: expected 200, got %d", i, rec.Code)
}
}
// The next distinct claim exceeds the per-subject quota.
over := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Number("9999")},
CreatedAt: now - 10,
Serial: 9999,
Nonce: bytes.Repeat([]byte{0xee}, tce.NonceSize),
}
ob, _ := protocol.EncodeClaim(over)
env := &transport.Envelope{TCE: ob, Signature: issuer.Sign(ob)}
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/v1/objects", &buf)
srv.HandlePut(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for quota exceeded, got %d", rec.Code)
}
}
func TestOneResponsePerRequest(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
now := uint64(time.Now().Unix())
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
CreatedAt: now,
ExpiresAt: now + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
resp1 := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: now + 5,
Nonce: bytes.Repeat([]byte{0x01}, tce.NonceSize),
}
r1, _ := protocol.EncodeApprovalResponse(resp1)
if _, err := sPut(ts, r1, approver.Sign(r1)); err != nil {
t.Fatal(err)
}
resp2 := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: now + 6,
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
}
r2, _ := protocol.EncodeApprovalResponse(resp2)
if _, err := sPut(ts, r2, approver.Sign(r2)); err == nil {
t.Fatal("expected second response to same request to be rejected")
}
}
func TestRateLimit(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
env := &transport.Envelope{TCE: cb, Signature: issuer.Sign(cb)}
got429 := false
for i := 0; i < 65; i++ {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
resp, _ := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
if resp.StatusCode == http.StatusTooManyRequests {
got429 = true
}
resp.Body.Close()
}
if !got429 {
t.Fatal("expected rate limit (429) after bursting PUT")
}
}
func TestRejectsOversizeBody(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
// An envelope whose base64 TCE exceeds the server body cap.
env := &transport.Envelope{TCE: bytes.Repeat([]byte{0xff}, 10000)}
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
resp, _ := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("expected 413, got %d", resp.StatusCode)
}
resp.Body.Close()
}

288
internal/server/store.go Normal file
View file

@ -0,0 +1,288 @@
// Package server is the trust relay: it stores signed TCE objects keyed by
// their content address and serves them over the JSON transport. It is a dumb
// store, not an authority.
//
// Invariants enforced here:
//
// - INV-1: this package never imports internal/identity/signer and never
// holds a signing key. It can store and serve, never forge.
// - INV-3: the only identifiers are cryptographic (public keys, object
// hashes). There are no database row ids in any stored or served object.
// - INV-5: the relay answers "who said what", never "is this allowed". It
// strict-decodes and content-addresses objects but does not apply trust
// policy; consumers verify signatures locally.
package server
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
// Store is a content-addressed, in-memory store of signed objects.
//
// It strict-decodes every object on arrival, recomputes its object ID from the
// TCE bytes (rejecting any envelope whose supplied ID disagrees), and indexes
// it for query. It never verifies signatures: that is the consumer's job.
type Store struct {
mu sync.RWMutex
// dir is the optional on-disk location. When empty the store is purely
// in-memory.
dir string
byID map[string]*transport.Envelope
byType map[string]map[string]struct{} // type -> id set
bySubject map[string]map[string]struct{} // claim subject address
byRecipient map[string]map[string]struct{} // request recipient address
byClaimID map[string]map[string]struct{} // revocation -> target claim id
byRequest map[string]map[string]struct{} // response -> request hash
// answeredRequests records request hashes that already have a stored
// response, to enforce the one-response-per-request rule.
answeredRequests map[string]struct{}
}
// maxPerSubject bounds how many claims a single subject may have in the store,
// to keep the in-memory indexes from growing without bound under a hostile or
// buggy publisher.
const maxPerSubject = 1000
// NewStore returns a store. If dir is non-empty, existing objects are loaded
// from disk and every subsequent Put is persisted there.
func NewStore(dir string) *Store {
s := &Store{
dir: dir,
byID: make(map[string]*transport.Envelope),
byType: make(map[string]map[string]struct{}),
bySubject: make(map[string]map[string]struct{}),
byRecipient: make(map[string]map[string]struct{}),
byClaimID: make(map[string]map[string]struct{}),
byRequest: make(map[string]map[string]struct{}),
answeredRequests: make(map[string]struct{}),
}
if dir != "" {
s.load()
}
return s
}
// Put stores one object from its raw TCE bytes and signature. It returns the
// authoritative object ID. The supplied objectID, if any, must match the
// recomputed one.
func (s *Store) Put(tceBytes, sig []byte, suppliedID string) (string, error) {
if len(tceBytes) > tce.MaxClaimTCE*2 {
return "", fmt.Errorf("server: object too large")
}
_, obj, err := transport.DecodeObject(tceBytes)
if err != nil {
return "", fmt.Errorf("server: rejected: %w", err)
}
id := tce.ComputeID(tceBytes).String()
if suppliedID != "" && suppliedID != id {
return "", fmt.Errorf("server: object_id %s does not match recomputed %s", suppliedID, id)
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.byID[id]; exists {
return id, nil // idempotent
}
if claim, ok := obj.(*protocol.Claim); ok {
sub := transport.AddrOf(claim.Subject)
if len(s.bySubject[sub]) >= maxPerSubject {
return "", fmt.Errorf("server: subject %s quota exceeded", sub)
}
}
if resp, ok := obj.(*protocol.ApprovalResponse); ok {
rh := resp.RequestHash.String()
if _, done := s.answeredRequests[rh]; done {
return "", fmt.Errorf("server: request %s already answered", rh)
}
}
s.addLocked(tceBytes, sig, id, obj)
if s.dir != "" {
if err := s.writeFile(id, tceBytes, sig); err != nil {
return "", fmt.Errorf("server: persist: %w", err)
}
}
return id, nil
}
// addLocked inserts an already-validated object into the in-memory indexes. The
// caller must hold s.mu.
func (s *Store) addLocked(tceBytes, sig []byte, id string, obj any) {
s.byID[id] = &transport.Envelope{
TCE: append([]byte(nil), tceBytes...),
Signature: append([]byte(nil), sig...),
}
typ := transport.ObjectTypeName(obj)
addKey(s.byType, typ, id)
switch o := obj.(type) {
case *protocol.Claim:
addKey(s.bySubject, transport.AddrOf(o.Subject), id)
case *protocol.ApprovalRequest:
addKey(s.byRecipient, transport.AddrOf(o.Recipient), id)
case *protocol.Revocation:
addKey(s.byClaimID, o.ClaimID.String(), id)
case *protocol.ApprovalResponse:
addKey(s.byRequest, o.RequestHash.String(), id)
s.answeredRequests[o.RequestHash.String()] = struct{}{}
}
}
// writeFile atomically persists an envelope to dir/<id>.json.
func (s *Store) writeFile(id string, tceBytes, sig []byte) error {
if err := os.MkdirAll(s.dir, 0o700); err != nil {
return err
}
tmp := filepath.Join(s.dir, id+".tmp")
final := filepath.Join(s.dir, id+".json")
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
enc := json.NewEncoder(f)
if err := enc.Encode(struct {
TCE []byte `json:"tce"`
Signature []byte `json:"signature"`
ObjectID string `json:"object_id"`
}{tceBytes, sig, id}); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, final)
}
// load replays persisted envelopes from disk into the in-memory indexes. A
// corrupt or integrity-failing file is skipped.
func (s *Store) load() {
entries, err := os.ReadDir(s.dir)
if err != nil {
return // no directory yet: start empty
}
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
raw, err := os.ReadFile(filepath.Join(s.dir, e.Name()))
if err != nil {
continue
}
var enc struct {
TCE []byte `json:"tce"`
Signature []byte `json:"signature"`
ObjectID string `json:"object_id"`
}
if err := json.Unmarshal(raw, &enc); err != nil {
continue
}
_, obj, err := transport.DecodeObject(enc.TCE)
if err != nil {
continue
}
id := tce.ComputeID(enc.TCE).String()
if enc.ObjectID != id {
continue // integrity check
}
if _, exists := s.byID[id]; exists {
continue
}
s.addLocked(enc.TCE, enc.Signature, id, obj)
}
}
// Get returns the stored envelope with its authoritative object_id and decoded
// `object` view filled in.
func (s *Store) Get(id string) (*transport.Envelope, error) {
s.mu.RLock()
env, ok := s.byID[id]
s.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("server: object %s not found", id)
}
return decorate(env)
}
// ClaimsBySubject returns all stored claims about the given subject address.
func (s *Store) ClaimsBySubject(addr string) ([]*transport.Envelope, error) {
return s.queryIndex(s.bySubject, addr)
}
// RequestsByRecipient returns all stored approval requests for a recipient.
func (s *Store) RequestsByRecipient(addr string) ([]*transport.Envelope, error) {
return s.queryIndex(s.byRecipient, addr)
}
// ResponsesForRequest returns all stored responses to a request hash.
func (s *Store) ResponsesForRequest(reqHash string) ([]*transport.Envelope, error) {
return s.queryIndex(s.byRequest, reqHash)
}
// RevocationsForClaim returns all stored revocations targeting a claim object
// ID.
func (s *Store) RevocationsForClaim(claimID string) ([]*transport.Envelope, error) {
return s.queryIndex(s.byClaimID, claimID)
}
func (s *Store) queryIndex(idx map[string]map[string]struct{}, key string) ([]*transport.Envelope, error) {
s.mu.RLock()
ids := idx[key]
out := make([]*transport.Envelope, 0, len(ids))
for id := range ids {
if env, ok := s.byID[id]; ok {
out = append(out, env)
}
}
s.mu.RUnlock()
for i, env := range out {
d, err := decorate(env)
if err != nil {
return nil, err
}
out[i] = d
}
return out, nil
}
// decorate fills the object_id and object view of a stored envelope.
func decorate(env *transport.Envelope) (*transport.Envelope, error) {
id := tce.ComputeID(env.TCE).String()
_, view, err := transport.BuildView(env.TCE)
if err != nil {
return nil, err
}
return &transport.Envelope{
TCE: env.TCE,
Signature: env.Signature,
ObjectID: id,
Object: view,
}, nil
}
func addKey(m map[string]map[string]struct{}, key, id string) {
set := m[key]
if set == nil {
set = make(map[string]struct{})
m[key] = set
}
set[id] = struct{}{}
}
// now is overridable in tests.
var now = time.Now

287
internal/tce/decoder.go Normal file
View file

@ -0,0 +1,287 @@
package tce
import (
"unicode/utf8"
)
// Decoder reads canonical TCE bytes.
//
// The decoder is strict: it accepts only the exact byte sequence the encoder
// would produce for a given object, and treats every other input as an error.
// In particular it never skips a field it does not understand. Skipping would
// mean two implementations computed different meanings for the same signed
// bytes while both saw a valid signature, so an old verifier could approve an
// object whose actual content it never examined.
//
// Every length prefix is checked against the remaining input and against the
// field's maximum before any allocation, so a hostile length cannot cause a
// large allocation or a long loop.
type Decoder struct {
buf []byte
off int
}
// NewDecoder returns a decoder reading b. The slice is not copied; the caller
// must not modify it while decoding.
func NewDecoder(b []byte) *Decoder { return &Decoder{buf: b} }
// Offset returns the current read position.
func (d *Decoder) Offset() int { return d.off }
// Remaining returns the number of unread bytes.
func (d *Decoder) Remaining() int { return len(d.buf) - d.off }
// End asserts that the input is fully consumed.
//
// Trailing bytes are an error rather than ignored data: an object with extra
// bytes appended has a different byte string, and therefore a different object
// ID and a different signature, from the object it appears to contain.
func (d *Decoder) End() error {
if d.off != len(d.buf) {
return ErrTrailing
}
return nil
}
// Header reads and validates the magic, object tag and object version.
func (d *Decoder) Header() (ObjectTag, error) {
if d.Remaining() < MagicLen {
return TagReserved, ErrTruncated
}
if string(d.buf[d.off:d.off+MagicLen]) != Magic {
return TagReserved, ErrMagic
}
d.off += MagicLen
if d.Remaining() < 1 {
return TagReserved, ErrTruncated
}
tag := ObjectTag(d.buf[d.off])
d.off++
if !knownTag(tag) {
return TagReserved, ErrObjectTag
}
ver, err := d.Uvarint()
if err != nil {
return TagReserved, fieldErr("version", err)
}
if ver != Version {
return TagReserved, ErrVersion
}
return tag, nil
}
// Uvarint reads a canonical LEB128 unsigned varint.
//
// A multi-byte encoding whose final group is zero is a longer spelling of a
// shorter value and is rejected, because permitting it would give the same
// number two encodings and therefore the same object two byte strings.
func (d *Decoder) Uvarint() (uint64, error) {
var n uint64
var shift uint
start := d.off
for {
if d.off >= len(d.buf) {
return 0, ErrTruncated
}
if d.off-start >= MaxUvarintBytes {
return 0, ErrUvarint
}
b := d.buf[d.off]
d.off++
if shift >= 64 || (shift == 63 && b > 1) {
return 0, ErrOverflow
}
n |= uint64(b&0x7f) << shift
if b&0x80 == 0 {
if d.off-start > 1 && b == 0x00 {
return 0, ErrNonMinimal
}
return n, nil
}
shift += 7
}
}
// RawBytes reads a length-prefixed byte string, enforcing maxLen.
//
// The returned slice aliases the decoder's buffer. Callers that retain the
// data must copy it; the object constructors in the protocol package do.
func (d *Decoder) RawBytes(maxLen int) ([]byte, error) {
n, err := d.Uvarint()
if err != nil {
return nil, err
}
// Compare against the remaining input first so that an absurd length is
// rejected before it is compared with anything else.
if n > uint64(d.Remaining()) {
return nil, ErrTruncated
}
if n > uint64(maxLen) {
return nil, ErrTooLong
}
b := d.buf[d.off : d.off+int(n)]
d.off += int(n)
return b, nil
}
// FixedBytes reads a length-prefixed byte string of exactly want bytes.
func (d *Decoder) FixedBytes(want int) ([]byte, error) {
b, err := d.RawBytes(want)
if err != nil {
return nil, err
}
if len(b) != want {
return nil, ErrFieldSize
}
return b, nil
}
// String reads a length-prefixed UTF-8 string and validates it.
func (d *Decoder) String(maxLen int) (string, error) {
b, err := d.RawBytes(maxLen)
if err != nil {
return "", err
}
if !utf8.Valid(b) {
return "", ErrUTF8
}
s := string(b)
for _, r := range s {
if r <= 0x1f || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
return "", ErrControlChar
}
}
return s, nil
}
// Identity reads an identity field and returns a copy of the public key.
//
// The key is copied so that a decoded object does not alias the input buffer,
// which means a caller cannot alter an identity after the object containing it
// has been verified.
func (d *Decoder) Identity() ([]byte, error) {
ver, err := d.Uvarint()
if err != nil {
return nil, err
}
if ver != AddressVersion {
return nil, ErrAddressVersion
}
b, err := d.FixedBytes(PubKeySize)
if err != nil {
return nil, err
}
out := make([]byte, PubKeySize)
copy(out, b)
return out, nil
}
// Timestamp reads a timestamp and validates its range.
func (d *Decoder) Timestamp(allowZero bool) (uint64, error) {
ts, err := d.Uvarint()
if err != nil {
return 0, err
}
if err := ValidateTimestamp(ts, allowZero); err != nil {
return 0, err
}
return ts, nil
}
// Value reads a typed value.
//
// Reserved and unknown tags are rejected. This is the opposite of the
// "ignore what you do not understand" rule common in extensible formats, and
// it is deliberate: in a signed protocol, skipping an unrecognised value means
// the verifier's view of the object differs from the signer's.
func (d *Decoder) Value() (Value, error) {
if d.Remaining() < 1 {
return Value{}, ErrTruncated
}
tag := ValueTag(d.buf[d.off])
d.off++
switch tag {
case ValNull:
return Null(), nil
case ValFalse:
return Bool(false), nil
case ValTrue:
return Bool(true), nil
case ValString:
s, err := d.String(MaxStringValue)
if err != nil {
return Value{}, err
}
return String(s), nil
case ValNumber:
b, err := d.RawBytes(MaxNumberToken)
if err != nil {
return Value{}, err
}
tok := string(b)
// A number must arrive in canonical form. Accepting "1.0" here would
// mean two byte strings encoded the same value.
if !IsCanonicalNumber(tok) {
return Value{}, ErrNumberFormat
}
return Number(tok), nil
default:
return Value{}, ErrValueTag
}
}
// Map reads a map, enforcing ascending key order, key uniqueness, the key
// grammar and the entry limit.
func (d *Decoder) Map(minEntries int) (map[string]Value, error) {
n, err := d.Uvarint()
if err != nil {
return nil, err
}
if n > MaxMapEntries {
return nil, ErrTooLong
}
if n < uint64(minEntries) {
return nil, ErrEmptyMap
}
// Each entry costs at least two bytes, so a count larger than the
// remaining input cannot be satisfied. Checking this before allocating
// prevents a small input from reserving a large map.
if n > uint64(d.Remaining()) {
return nil, ErrTruncated
}
m := make(map[string]Value, n)
prev := ""
for i := uint64(0); i < n; i++ {
kb, err := d.RawBytes(MaxKeyLen)
if err != nil {
return nil, err
}
key := string(kb)
if err := ValidateKey(key); err != nil {
return nil, err
}
if i > 0 {
switch {
case key == prev:
return nil, ErrDuplicateKey
case key < prev:
// Out-of-order entries parse, but would give one map two
// encodings, so they are rejected.
return nil, ErrKeyOrder
}
}
v, err := d.Value()
if err != nil {
return nil, err
}
m[key] = v
prev = key
}
return m, nil
}

309
internal/tce/encoder.go Normal file
View file

@ -0,0 +1,309 @@
package tce
import (
"sort"
"unicode/utf8"
)
// Encoder builds canonical TCE bytes.
//
// The encoder is append-only and records the first error it encounters,
// after which every further operation is a no-op. This means a caller writes
// a straight sequence of field writes and checks once at the end, without a
// partially built object ever escaping.
type Encoder struct {
buf []byte
err error
}
// NewEncoder returns an encoder with space reserved for a typical object.
func NewEncoder() *Encoder {
return &Encoder{buf: make([]byte, 0, 256)}
}
// Err returns the first error recorded, if any.
func (e *Encoder) Err() error { return e.err }
// fail records the first error.
func (e *Encoder) fail(field string, err error) {
if e.err == nil {
e.err = fieldErr(field, err)
}
}
// Bytes returns the encoded object, or an error if any write failed.
//
// The returned slice is a copy, so a caller cannot alter the encoder's buffer
// afterwards and no two callers share backing storage.
func (e *Encoder) Bytes() ([]byte, error) {
if e.err != nil {
return nil, e.err
}
out := make([]byte, len(e.buf))
copy(out, e.buf)
return out, nil
}
// Header writes the magic, the object tag and the object version. It must be
// the first call on an encoder.
func (e *Encoder) Header(tag ObjectTag) {
if !knownTag(tag) {
e.fail("object tag", ErrObjectTag)
return
}
e.buf = append(e.buf, Magic...)
e.buf = append(e.buf, byte(tag))
e.Uvarint(Version)
}
// Uvarint appends a canonical LEB128 unsigned varint.
func (e *Encoder) Uvarint(n uint64) {
if e.err != nil {
return
}
e.buf = AppendUvarint(e.buf, n)
}
// AppendUvarint appends the canonical LEB128 encoding of n to dst.
//
// Canonical means shortest: the loop emits a continuation byte only while
// bits remain, so no encoding ever ends in a redundant 0x00 group.
func AppendUvarint(dst []byte, n uint64) []byte {
for n >= 0x80 {
dst = append(dst, byte(n)|0x80)
n >>= 7
}
return append(dst, byte(n))
}
// UvarintLen returns the number of bytes AppendUvarint would write.
func UvarintLen(n uint64) int {
l := 1
for n >= 0x80 {
n >>= 7
l++
}
return l
}
// RawBytes appends a length-prefixed byte string with no length limit of its
// own. Callers use FixedBytes or the field-specific helpers instead where a
// limit applies.
func (e *Encoder) RawBytes(field string, b []byte) {
if e.err != nil {
return
}
e.buf = AppendUvarint(e.buf, uint64(len(b)))
e.buf = append(e.buf, b...)
}
// FixedBytes appends a length-prefixed byte string that must have exactly the
// given length.
//
// The length prefix is written even though the width is fixed, so that every
// field stays self-delimiting and a decoder never depends on out-of-band
// knowledge of a field's size.
func (e *Encoder) FixedBytes(field string, b []byte, want int) {
if e.err != nil {
return
}
if len(b) != want {
e.fail(field, ErrFieldSize)
return
}
e.RawBytes(field, b)
}
// String appends a length-prefixed UTF-8 string after validating it.
func (e *Encoder) String(field, s string, maxLen int) {
if e.err != nil {
return
}
if err := ValidateString(s, maxLen); err != nil {
e.fail(field, err)
return
}
e.RawBytes(field, []byte(s))
}
// Identity appends an identity field: the address version, then the
// length-prefixed raw public key.
//
// The raw key is encoded rather than the bech32m address text. The address is
// a presentation format; the key is the identity. Signing the key means a
// change to address rendering cannot invalidate existing signatures.
func (e *Encoder) Identity(field string, pubkey []byte) {
if e.err != nil {
return
}
if len(pubkey) != PubKeySize {
e.fail(field, ErrFieldSize)
return
}
e.Uvarint(AddressVersion)
e.RawBytes(field, pubkey)
}
// Timestamp appends a timestamp, enforcing the protocol's range.
//
// allowZero permits the single exception where 0 means "does not expire".
func (e *Encoder) Timestamp(field string, ts uint64, allowZero bool) {
if e.err != nil {
return
}
if err := ValidateTimestamp(ts, allowZero); err != nil {
e.fail(field, err)
return
}
e.Uvarint(ts)
}
// ValidateTimestamp checks a timestamp against the specification's bounds.
func ValidateTimestamp(ts uint64, allowZero bool) error {
if allowZero && ts == 0 {
return nil
}
if ts < MinTimestamp || ts > MaxTimestamp {
return ErrTimestamp
}
return nil
}
// ValidateString applies the protocol's string rules.
//
// No normalisation is performed. The bytes are signed exactly as supplied,
// because silently rewriting a user's text before signing it would mean the
// user signs something other than what they reviewed.
func ValidateString(s string, maxLen int) error {
if len(s) > maxLen {
return ErrTooLong
}
if !utf8.ValidString(s) {
return ErrUTF8
}
for _, r := range s {
// utf8.ValidString already rejects surrogates and overlong forms.
if r <= 0x1f || r == 0x7f || (r >= 0x80 && r <= 0x9f) {
return ErrControlChar
}
}
return nil
}
// Value appends a typed claim or payload value.
func (e *Encoder) Value(field string, v Value) {
if e.err != nil {
return
}
switch v.tag {
case ValNull, ValFalse, ValTrue:
e.buf = append(e.buf, byte(v.tag))
case ValString:
if err := ValidateString(v.str, MaxStringValue); err != nil {
e.fail(field, err)
return
}
e.buf = append(e.buf, byte(ValString))
e.RawBytes(field, []byte(v.str))
case ValNumber:
canon, err := CanonicalNumber(v.str)
if err != nil {
e.fail(field, err)
return
}
e.buf = append(e.buf, byte(ValNumber))
e.RawBytes(field, []byte(canon))
default:
e.fail(field, ErrValueTag)
}
}
// Map appends a map of keys to values in canonical order.
//
// Entries are sorted by raw key bytes, unsigned bytewise ascending. Duplicate
// keys are an error rather than a last-one-wins situation, because leaving
// the winner to the implementation would mean two conforming encoders
// disagreed about the meaning of the same input.
func (e *Encoder) Map(field string, m map[string]Value, minEntries int) {
if e.err != nil {
return
}
if len(m) < minEntries {
e.fail(field, ErrEmptyMap)
return
}
if len(m) > MaxMapEntries {
e.fail(field, ErrTooLong)
return
}
keys := make([]string, 0, len(m))
for k := range m {
if err := ValidateKey(k); err != nil {
e.fail(field, err)
return
}
keys = append(keys, k)
}
// Go string comparison is bytewise on the underlying bytes, which is the
// ordering the specification requires.
sort.Strings(keys)
// A Go map cannot hold duplicate keys, but the check is kept so that the
// invariant is enforced here as well as in the decoder.
for i := 1; i < len(keys); i++ {
if keys[i] == keys[i-1] {
e.fail(field, ErrDuplicateKey)
return
}
}
e.Uvarint(uint64(len(keys)))
for _, k := range keys {
e.RawBytes(field, []byte(k))
e.Value(field, m[k])
if e.err != nil {
return
}
}
}
// ValidateKey applies the map key grammar from PROTOCOL.md section 6.2:
//
// [a-z][a-z0-9]*([._-][a-z0-9]+)*
//
// This is a lexical rule with no semantics attached. The protocol never
// interprets a key; the restriction exists so that keys sort predictably and
// cannot carry homoglyphs or bidirectional overrides.
func ValidateKey(k string) error {
if len(k) == 0 || len(k) > MaxKeyLen {
return ErrTooLong
}
if k[0] < 'a' || k[0] > 'z' {
return ErrKeyGrammar
}
prevSep := false
for i := 1; i < len(k); i++ {
c := k[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
prevSep = false
case c == '.' || c == '_' || c == '-':
// No repeated separators and none at the end.
if prevSep || i == len(k)-1 {
return ErrKeyGrammar
}
prevSep = true
default:
return ErrKeyGrammar
}
}
return nil
}
// checkSize enforces the per-object maximum size.
func (e *Encoder) checkSize(limit int) {
if e.err == nil && len(e.buf) > limit {
e.err = ErrObjectTooLarge
}
}

33
internal/tce/fuzz.go Normal file
View file

@ -0,0 +1,33 @@
//go:build gofuzz
package tce
// Fuzz is the OSS-Fuzz entry point for the primitive TCE codec. It asserts two
// totality properties of PROTOCOL.md section 12.4 against arbitrary input:
//
// - the decoder never panics, hangs or returns a usable object alongside an
// error (totality); the decoder is walked field by field and each read
// ignores its result;
// - CanonicalNumber (section 5.1) is total over arbitrary text and only ever
// produces an already-canonical token, so a malformed number is rejected
// rather than silently accepted.
//
// The function is compiled only under the "gofuzz" build tag (go-fuzz /
// OSS-Fuzz). Under that tag the Go test files are excluded, so there is no
// clash with the testing.F-based fuzz targets in the package.
func Fuzz(data []byte) int {
d := NewDecoder(data)
_, _ = d.Header()
_, _ = d.Uvarint()
_, _ = d.String(1 << 20)
_, _ = d.Map(0)
_, _ = d.Timestamp(true)
_, _ = d.Identity()
// Signal interesting inputs that survive canonicalization, guiding the
// fuzzer toward the number parser's branches.
if c, err := CanonicalNumber(string(data)); err == nil && IsCanonicalNumber(c) {
return 1
}
return 0
}

107
internal/tce/fuzz_test.go Normal file
View file

@ -0,0 +1,107 @@
package tce
import (
"bytes"
"strings"
"testing"
)
// FuzzUvarint asserts totality and canonicality of the uvarint primitive:
// arbitrary bytes never panic, and any successful decode is minimal.
//
// A successful decode necessarily consumed the canonical encoding of its
// value, because the decoder rejects non-minimal forms. Re-encoding the value
// must therefore reproduce the consumed bytes exactly; if it did not, the
// grammar would be ambiguous and the encoding malleable.
func FuzzUvarint(f *testing.F) {
f.Add([]byte(nil))
f.Add([]byte{0x00})
f.Add([]byte{0x81, 0x00})
f.Add([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01})
f.Add(append(AppendUvarint(nil, 1<<40), 0x00)) // success then a trailing byte
f.Fuzz(func(t *testing.T, b []byte) {
d := NewDecoder(b)
v, err := d.Uvarint()
if err != nil {
return
}
consumed := b[:d.Offset()]
canon := AppendUvarint(nil, v)
if !bytes.Equal(consumed, canon) {
t.Fatalf("accepted non-canonical uvarint %x for value %d", consumed, v)
}
})
}
// FuzzDecodePrimitives asserts that the value and map decoders are total over
// arbitrary bytes: they never panic, never hang, and an accepted map is
// exactly reproducible by the encoder.
func FuzzDecodePrimitives(f *testing.F) {
f.Add([]byte(nil))
f.Add([]byte{0x01, 0x01, 'a', 0x02})
f.Add([]byte{0x02, 0x01, 'b', 0x02, 0x01, 'a', 0x01})
f.Add([]byte{0x00})
f.Add([]byte{0x04, 0x01, '0'})
f.Add([]byte{0x04, 0x03, '1', '.', '0'})
f.Add([]byte{0x02, 0x01, 'a', 0x02, 0x01, 'b', 0x02, 0x01, 'c', 0x01})
f.Add([]byte{0x04, 0x0c, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '1', '2', '3', '4'})
f.Add([]byte{0x01, 0x05, 0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd}) // UTF-8 "你好"
f.Fuzz(func(t *testing.T, b []byte) {
d := NewDecoder(b)
m, err := d.Map(0)
if err != nil {
return
}
e := NewEncoder()
e.Map("claims", m, 0)
out, err := e.Bytes()
if err != nil {
t.Fatalf("encoder rejected the decoder's own output: %v", err)
}
if !bytes.Equal(b[:d.Offset()], out) {
t.Fatalf("encode(decode(b)) != b:\n%x\n%x", b[:d.Offset()], out)
}
})
}
// FuzzStringValidation asserts the string validator never panics and agrees
// with itself on arbitrary byte strings.
func FuzzStringValidation(f *testing.F) {
f.Add("")
f.Add("valid")
f.Add("\xff\xfe")
f.Add("\x00")
// Long string: its length prefix is a multi-byte uvarint, the case that
// originally exposed the single-byte-prefix assumption in the decoder.
f.Add(strings.Repeat("a", 200))
f.Add("héllo, 世界")
f.Fuzz(func(t *testing.T, s string) {
if err := ValidateString(s, MaxStringValue); err != nil {
return
}
// An accepted string must be reproducible: encoding it and decoding
// the length prefix must yield the same bytes back. The prefix is a
// uvarint, so it may be more than one byte for long strings.
e := NewEncoder()
e.String("s", s, MaxStringValue)
out, err := e.Bytes()
if err != nil {
t.Fatalf("encode rejected a string ValidateString accepted: %v", err)
}
d2 := NewDecoder(out)
n, perr := d2.Uvarint()
if perr != nil {
t.Fatalf("decoding the length prefix: %v", perr)
}
if n != uint64(len(s)) {
t.Fatalf("length prefix %d != %d", n, len(s))
}
chunk := out[d2.Offset():]
if string(chunk) != s {
t.Fatalf("string altered in encoding")
}
})
}

94
internal/tce/id.go Normal file
View file

@ -0,0 +1,94 @@
package tce
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
)
// ID is the content address of a TCE object: SHA-256 over its canonical
// bytes.
//
// ID is a distinct type rather than a []byte so that a hash cannot be passed
// where a message is expected. Nothing in the tree signs or verifies an ID:
// the signature covers the TCE bytes, and the ID exists only to name and
// reference the object (see docs/IMPLEMENTATION_NOTES.md property 1).
type ID [HashSize]byte
// ErrBadID is returned when a hex string is not a valid object ID.
var ErrBadID = errors.New("tce: malformed object id")
// ComputeID returns the content address of the given canonical bytes.
func ComputeID(tceBytes []byte) ID {
return ID(sha256.Sum256(tceBytes))
}
// ParseID decodes a lowercase hex object ID.
func ParseID(s string) (ID, error) {
var id ID
if len(s) != HashSize*2 {
return id, ErrBadID
}
// Reject uppercase so that one ID has one textual form.
for i := 0; i < len(s); i++ {
c := s[i]
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
return id, ErrBadID
}
}
b, err := hex.DecodeString(s)
if err != nil {
return id, ErrBadID
}
copy(id[:], b)
return id, nil
}
// IDFromBytes builds an ID from exactly 32 bytes.
func IDFromBytes(b []byte) (ID, error) {
var id ID
if len(b) != HashSize {
return id, ErrBadID
}
copy(id[:], b)
return id, nil
}
// String renders the ID as lowercase hex.
func (id ID) String() string { return hex.EncodeToString(id[:]) }
// Bytes returns a copy of the raw hash.
func (id ID) Bytes() []byte {
out := make([]byte, HashSize)
copy(out, id[:])
return out
}
// Equal compares two IDs in constant time.
//
// The comparison is not obviously timing-sensitive, but the analysis needed to
// prove any individual case safe is not worth repeating, and the cost is
// negligible.
func (id ID) Equal(other ID) bool {
return subtle.ConstantTimeCompare(id[:], other[:]) == 1
}
// IsZero reports whether the ID is unset.
func (id ID) IsZero() bool {
var zero ID
return subtle.ConstantTimeCompare(id[:], zero[:]) == 1
}
// MarshalText implements encoding.TextMarshaler.
func (id ID) MarshalText() ([]byte, error) { return []byte(id.String()), nil }
// UnmarshalText implements encoding.TextUnmarshaler.
func (id *ID) UnmarshalText(b []byte) error {
parsed, err := ParseID(string(b))
if err != nil {
return err
}
*id = parsed
return nil
}

114
internal/tce/id_test.go Normal file
View file

@ -0,0 +1,114 @@
package tce_test
import (
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
func TestIDAccessors(t *testing.T) {
// ComputeID is SHA-256 over the canonical bytes.
id := tce.ComputeID([]byte("trust.n1ko.dev/tce/1"))
if id.IsZero() {
t.Fatal("a computed ID must not be zero")
}
if !id.Equal(id) {
t.Fatal("an ID must equal itself")
}
if len(id.String()) != 64 {
t.Fatalf("ID hex must be 64 chars, got %d", len(id.String()))
}
if len(id.Bytes()) != 32 {
t.Fatalf("ID bytes must be 32, got %d", len(id.Bytes()))
}
// Bytes is a copy: mutating it must not change the ID.
raw := id.Bytes()
raw[0] ^= 0xff
if id.Bytes()[0] == raw[0] {
t.Fatal("Bytes did not return an independent copy")
}
zero := tce.ID{}
if !zero.IsZero() {
t.Fatal("zero ID must report IsZero")
}
if id.IsZero() {
t.Fatal("non-zero ID must not report IsZero")
}
}
func TestParseID(t *testing.T) {
// accurate 64-char lowercase hex
good := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
id, err := tce.ParseID(good)
if err != nil {
t.Fatalf("ParseID good: %v", err)
}
if id.String() != good {
t.Fatalf("round-trip mismatch: %s != %s", id.String(), good)
}
bad := []string{
good[:63], // too short
good + "0", // too long
good[:63] + "G", // non-hex
good[:63] + "A", // uppercase rejected
}
for _, b := range bad {
if _, err := tce.ParseID(b); err == nil {
t.Errorf("ParseID(%q): expected error", b)
}
}
}
func TestIDFromBytes(t *testing.T) {
b := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}
id, err := tce.IDFromBytes(b)
if err != nil {
t.Fatalf("IDFromBytes: %v", err)
}
if id.Bytes()[0] != 0 || id.Bytes()[31] != 31 {
t.Fatal("IDFromBytes copied incorrectly")
}
if _, err := tce.IDFromBytes(b[:31]); err == nil {
t.Fatal("IDFromBytes wrong length must error")
}
}
func TestIDText(t *testing.T) {
good := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
id, err := tce.ParseID(good)
if err != nil {
t.Fatal(err)
}
text, err := id.MarshalText()
if err != nil {
t.Fatalf("MarshalText: %v", err)
}
if string(text) != good {
t.Fatalf("MarshalText = %q, want %q", text, good)
}
var back tce.ID
if err := back.UnmarshalText(text); err != nil {
t.Fatalf("UnmarshalText: %v", err)
}
if !back.Equal(id) {
t.Fatal("UnmarshalText did not reconstruct the ID")
}
if err := back.UnmarshalText([]byte("zz")); err == nil {
t.Fatal("UnmarshalText must reject bad hex")
}
}
func TestComputeIDStable(t *testing.T) {
a := tce.ComputeID([]byte("abc"))
b := tce.ComputeID([]byte("abc"))
c := tce.ComputeID([]byte("abd"))
if !a.Equal(b) {
t.Fatal("same input must yield equal IDs")
}
if a.Equal(c) {
t.Fatal("different input must yield different IDs")
}
}

View file

@ -0,0 +1,110 @@
package tce_test
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strconv"
"testing"
)
// scanPackage finds every non-test .go source file in dir.
func scanPackage(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read dir %s: %v", dir, err)
}
var files []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if filepath.Ext(name) != ".go" {
continue
}
if !isTestFile(name) {
files = append(files, filepath.Join(dir, name))
}
}
return files
}
func isTestFile(name string) bool {
return len(name) > 8 && name[len(name)-8:] == "_test.go"
}
// importsOf returns the import paths declared in a source file.
func importsOf(t *testing.T, path string) []string {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
var out []string
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil {
t.Fatalf("bad import path in %s: %v", path, err)
}
out = append(out, p)
}
return out
}
// sourceOf returns the raw text of a source file.
func sourceOf(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func TestNoJSONImport(t *testing.T) {
for _, f := range scanPackage(t, ".") {
for _, imp := range importsOf(t, f) {
if imp == "encoding/json" {
t.Errorf("%s imports encoding/json; PROTOCOL.md forbids JSON in the wire codec", f)
}
}
}
}
func TestNoSigningInCodec(t *testing.T) {
re := regexp.MustCompile(`ed25519\.(Sign|NewKeyFromSeed|GenerateKey)`)
for _, f := range scanPackage(t, ".") {
if re.MatchString(sourceOf(t, f)) {
t.Errorf("%s performs ed25519 signing; signing must live only in internal/identity/signer", f)
}
}
}
func TestAllowedImports(t *testing.T) {
allowed := map[string]bool{
"crypto/sha256": true,
"crypto/subtle": true,
"encoding/hex": true,
"errors": true,
"fmt": true,
"math/big": true,
"sort": true,
"unicode/utf8": true,
"strconv": true,
}
for _, f := range scanPackage(t, ".") {
for _, imp := range importsOf(t, f) {
if imp == "go/token" || imp == "go/ast" || imp == "os" || imp == "path/filepath" || imp == "regexp" {
continue // test-only helpers
}
if !allowed[imp] {
t.Errorf("%s imports %s, which is outside the allowed tce dependency set", f, imp)
}
}
}
}

242
internal/tce/number.go Normal file
View file

@ -0,0 +1,242 @@
package tce
import "math/big"
// Number canonicalization, implementing PROTOCOL.md section 5.1.
//
// Numbers are carried as decimal text rather than as binary floating point. A
// JSON number is an arbitrary-precision decimal literal, so 1, 1.0 and 1e0 are
// one value written three ways. Converting through an IEEE-754 double would
// lose precision above 2^53 and would make the signed bytes depend on the
// implementation's parsing and rounding, which is exactly the ambiguity the
// canonical encoding exists to remove.
//
// The canonical form is plain decimal with no exponent: an optional minus
// sign, digits without a leading zero, and an optional fractional part without
// trailing zeros. Negative zero is not representable and canonicalizes to "0".
// CanonicalNumber reduces a JSON number token to its unique canonical form.
//
// The input must be the exact source token as it appeared in the document.
// The output is ASCII and is what gets encoded under value tag 0x04.
func CanonicalNumber(token string) (string, error) {
if len(token) == 0 || len(token) > MaxNumberSource {
return "", ErrNumberFormat
}
intPart, fracPart, expPart, negative, err := splitNumber(token)
if err != nil {
return "", err
}
// The exponent digit count is bounded before conversion so that a token
// such as 1e999999999 is refused rather than driving a huge shift.
if len(expPart) > 4 {
return "", ErrNumberRange
}
exp := 0
expNeg := false
if len(expPart) > 0 {
i := 0
if expPart[0] == '+' || expPart[0] == '-' {
expNeg = expPart[0] == '-'
i = 1
}
digits := expPart[i:]
if len(digits) == 0 || len(digits) > 4 {
return "", ErrNumberRange
}
for _, c := range []byte(digits) {
exp = exp*10 + int(c-'0')
}
if expNeg {
exp = -exp
}
}
// value = sign * mantissa * 10^scale
digits := intPart + fracPart
scale := exp - len(fracPart)
mantissa, ok := new(big.Int).SetString(digits, 10)
if !ok {
return "", ErrNumberFormat
}
if mantissa.Sign() == 0 {
// Maps -0, 0.0 and 0e10 all to "0".
return "0", nil
}
// Strip factors of ten that exist only to pad the fraction.
ten := big.NewInt(10)
qr := new(big.Int)
rem := new(big.Int)
for scale < 0 {
qr.QuoRem(mantissa, ten, rem)
if rem.Sign() != 0 {
break
}
mantissa.Set(qr)
scale++
}
// Bound the work before materialising the decimal form: a positive scale
// appends that many zeros, so it must be checked before the string is
// built rather than after.
ds := mantissa.String()
if ds[0] == '-' {
ds = ds[1:]
}
if scale > 0 && len(ds)+scale > MaxNumberIntDigs {
return "", ErrNumberRange
}
if scale < 0 && -scale > MaxNumberFracDig+len(ds) {
return "", ErrNumberRange
}
var intDigits, fracDigits string
if scale >= 0 {
intDigits = ds + zeros(scale)
} else {
point := len(ds) + scale
if point <= 0 {
intDigits = "0"
fracDigits = zeros(-point) + ds
} else {
intDigits = ds[:point]
fracDigits = ds[point:]
}
}
if len(trimLeadingZeros(intDigits)) > MaxNumberIntDigs {
return "", ErrNumberRange
}
if len(fracDigits) > MaxNumberFracDig {
return "", ErrNumberRange
}
n := len(intDigits)
if negative {
n++
}
if len(fracDigits) > 0 {
n += 1 + len(fracDigits)
}
if n > MaxNumberToken {
return "", ErrNumberRange
}
out := make([]byte, 0, n)
if negative {
out = append(out, '-')
}
out = append(out, intDigits...)
if len(fracDigits) > 0 {
out = append(out, '.')
out = append(out, fracDigits...)
}
return string(out), nil
}
// splitNumber validates the JSON number grammar from RFC 8259 and returns its
// parts:
//
// -? ( 0 | [1-9][0-9]* ) ( "." [0-9]+ )? ( [eE] [+-]? [0-9]+ )?
//
// The grammar is checked by hand rather than with a regular expression so
// that the accepted language is visible and no regexp engine behaviour is
// involved in deciding what gets signed.
func splitNumber(s string) (intPart, fracPart, expPart string, negative bool, err error) {
i := 0
if i < len(s) && s[i] == '-' {
negative = true
i++
}
// Integer part: a single 0, or a non-zero digit followed by digits.
start := i
if i >= len(s) {
return "", "", "", false, ErrNumberFormat
}
if s[i] == '0' {
i++
} else if s[i] >= '1' && s[i] <= '9' {
for i < len(s) && isDigit(s[i]) {
i++
}
} else {
return "", "", "", false, ErrNumberFormat
}
intPart = s[start:i]
// Leading zeros are rejected by the grammar above, which is what makes
// "01" invalid rather than a second spelling of 1.
if len(intPart) > 1 && intPart[0] == '0' {
return "", "", "", false, ErrNumberFormat
}
// Optional fraction.
if i < len(s) && s[i] == '.' {
i++
fs := i
for i < len(s) && isDigit(s[i]) {
i++
}
if i == fs {
return "", "", "", false, ErrNumberFormat
}
fracPart = s[fs:i]
}
// Optional exponent.
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
i++
es := i
if i < len(s) && (s[i] == '+' || s[i] == '-') {
i++
}
ds := i
for i < len(s) && isDigit(s[i]) {
i++
}
if i == ds {
return "", "", "", false, ErrNumberFormat
}
expPart = s[es:i]
}
if i != len(s) {
return "", "", "", false, ErrNumberFormat
}
return intPart, fracPart, expPart, negative, nil
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func zeros(n int) string {
if n <= 0 {
return ""
}
b := make([]byte, n)
for i := range b {
b[i] = '0'
}
return string(b)
}
func trimLeadingZeros(s string) string {
i := 0
for i < len(s)-1 && s[i] == '0' {
i++
}
return s[i:]
}
// IsCanonicalNumber reports whether token is already in canonical form. The
// decoder uses this to reject any other spelling rather than silently
// accepting it.
func IsCanonicalNumber(token string) bool {
c, err := CanonicalNumber(token)
return err == nil && c == token
}

120
internal/tce/number_test.go Normal file
View file

@ -0,0 +1,120 @@
package tce_test
import (
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// TestCanonicalNumber exercises section 5.1: arbitrary-precision decimals must
// collapse to one canonical spelling, and malformed tokens must be refused.
func TestCanonicalNumber(t *testing.T) {
cases := []struct {
in string
want string
wantErr bool
}{
// Integers and their redundant spellings.
{"1", "1", false},
{"1.0", "1", false},
{"1e0", "1", false},
{"1E0", "1", false},
{"1.00", "1", false},
{"001", "", true}, // leading zero
{"0", "0", false},
{"0.0", "0", false},
{"-0", "0", false},
{"-0.0", "0", false},
{"0e10", "0", false},
{"-0.000", "0", false},
// Fractions (no trailing zeros, no leading zero in int part).
{"0.1", "0.1", false},
{"0.10", "0.1", false},
{".5", "", true}, // missing integer part
{"1.", "", true}, // missing fraction digits
{"1.2.3", "", true},
// Exponents move the point and strip factors of ten.
{"123e2", "12300", false},
{"123E-2", "1.23", false},
{"1.5e1", "15", false},
{"150e-2", "1.5", false},
{"1e+2", "100", false},
{"1e-2", "0.01", false},
{"1e999999999", "", true}, // exponent too large
{"1e", "", true}, // exponent without digits
{"1e+x", "", true},
{"1e-", "", true},
// Negatives.
{"-1", "-1", false},
{"-1.0", "-1", false},
{"-0.5", "-0.5", false},
{"-1e1", "-10", false},
// Grammar rejections.
{"", "", true},
{"abc", "", true},
{"+1", "", true}, // sign must be minus
{" 1", "", true}, // no whitespace
{"1 ", "", true},
{"0x1", "", true},
{"1_000", "", true},
{" Infinity", "", true},
{"NaN", "", true},
// Range: large integers and deep fractions are bounded.
{"123456789012345678901234567890", "123456789012345678901234567890", false},
{"1e40", "", true}, // far beyond max integer digits
}
for _, c := range cases {
got, err := tce.CanonicalNumber(c.in)
if c.wantErr {
if err == nil {
t.Errorf("CanonicalNumber(%q): expected error, got %q", c.in, got)
}
continue
}
if err != nil {
t.Errorf("CanonicalNumber(%q): unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("CanonicalNumber(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestIsCanonicalNumber checks the decoder's accept/reject predicate.
func TestIsCanonicalNumber(t *testing.T) {
if !tce.IsCanonicalNumber("1.5") {
t.Error("1.5 is canonical")
}
if tce.IsCanonicalNumber("1.50") {
t.Error("1.50 is not canonical")
}
if tce.IsCanonicalNumber("01") {
t.Error("01 must be rejected")
}
}
// TestNumberRoundTripEquality confirms numbers that mean the same value
// canonicalize identically, so they encode to the same bytes.
func TestNumberRoundTripEquality(t *testing.T) {
forms := []string{"1", "1.0", "1e0", "1E0", "1.00", "001"}
base, _ := tce.CanonicalNumber("1")
for _, f := range forms {
c, err := tce.CanonicalNumber(f)
if err != nil {
// 001 is rejected; that is expected and not an equality case.
if f == "001" {
continue
}
t.Fatalf("CanonicalNumber(%q): %v", f, err)
}
if c != base {
t.Errorf("CanonicalNumber(%q) = %q, want %q", f, c, base)
}
}
}

View file

@ -0,0 +1,580 @@
package tce
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"strings"
"testing"
)
// Unit tests for the TCE primitives, mapping to PROTOCOL.md sections 2, 4,
// 5, 6, 7 and 12. The frozen vectors are exercised separately in
// vectors_test.go; these tests cover the individual rules with hand-built
// inputs.
func mustEncode(t *testing.T, build func(e *Encoder)) []byte {
t.Helper()
e := NewEncoder()
build(e)
b, err := e.Bytes()
if err != nil {
t.Fatalf("encode: %v", err)
}
return b
}
// claimBytes builds a minimal valid claim TCE for splicing into malformed
// inputs. It is the same shape as the frozen claim/boolean vector.
func claimBytes(t *testing.T) []byte {
t.Helper()
issuer := make([]byte, PubKeySize)
subject := make([]byte, PubKeySize)
copy(issuer, []byte{0x8a})
copy(subject, []byte{0x81})
return mustEncode(t, func(e *Encoder) {
e.Header(TagClaim)
e.Identity("issuer", issuer)
e.Identity("subject", subject)
e.Map("claims", map[string]Value{"example.flag": Bool(true)}, 1)
e.Timestamp("created_at", 1_700_000_000, false)
e.Timestamp("expires_at", 1_700_086_400, true)
e.Uvarint(1)
e.FixedBytes("nonce", []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, NonceSize)
})
}
func TestHeader(t *testing.T) {
good := claimBytes(t)
// The first 22 bytes are magic plus the object tag, then the version.
if !bytes.HasPrefix(good, []byte(Magic)) {
t.Fatal("object does not start with the magic")
}
t.Run("round trip", func(t *testing.T) {
d := NewDecoder(good)
tag, err := d.Header()
if err != nil {
t.Fatalf("header: %v", err)
}
if tag != TagClaim {
t.Fatalf("tag = %v, want claim", tag)
}
})
t.Run("empty input", func(t *testing.T) {
d := NewDecoder(nil)
if _, err := d.Header(); !errors.Is(err, ErrTruncated) {
t.Fatalf("empty input: err = %v, want ErrTruncated", err)
}
})
t.Run("truncated magic", func(t *testing.T) {
d := NewDecoder(good[:MagicLen-1])
if _, err := d.Header(); !errors.Is(err, ErrTruncated) {
t.Fatalf("truncated magic: err = %v", err)
}
})
t.Run("wrong magic", func(t *testing.T) {
// Bump the framing version digit inside the magic.
bad := bytes.Replace(good, []byte("tce/1\x00"), []byte("tce/2\x00"), 1)
d := NewDecoder(bad)
if _, err := d.Header(); !errors.Is(err, ErrMagic) {
t.Fatalf("wrong magic: err = %v", err)
}
})
for name, tag := range map[string]byte{
"unknown tag 0x7f": 0x7f,
"reserved tag 0x00": 0x00,
} {
t.Run(name, func(t *testing.T) {
mut := make([]byte, len(good))
copy(mut, good)
mut[MagicLen] = tag
d := NewDecoder(mut)
if _, err := d.Header(); !errors.Is(err, ErrObjectTag) {
t.Fatalf("err = %v, want ErrObjectTag", err)
}
})
}
t.Run("unknown version", func(t *testing.T) {
d := NewDecoder(good)
if _, err := d.Header(); err != nil {
t.Fatal(err)
}
// Re-run a decoder whose version field says 2.
mut := splatVersion(t, good, 2)
if _, err := NewDecoder(mut).Header(); !errors.Is(err, ErrVersion) {
t.Fatalf("unknown version: err = %v", err)
}
})
t.Run("non-minimal version uvarint", func(t *testing.T) {
// Replace the single-byte version (0x01) with its two-byte spelling.
off := MagicLen + 1
mut := make([]byte, 0, len(good)+1)
mut = append(mut, good[:off]...)
mut = append(mut, 0x81, 0x00)
mut = append(mut, good[off+1:]...)
if _, err := NewDecoder(mut).Header(); !errors.Is(err, ErrNonMinimal) {
t.Fatalf("non-minimal uvarint: err = %v", err)
}
})
}
// splatVersion rewrites the version uvarint of an object to v.
func splatVersion(t *testing.T, b []byte, v uint64) []byte {
t.Helper()
off := MagicLen + 1
out := make([]byte, 0, len(b))
out = append(out, b[:off]...)
out = AppendUvarint(out, v)
out = append(out, b[off+1:]...)
return out
}
func TestUvarintRoundTrip(t *testing.T) {
values := []uint64{0, 1, 2, 127, 128, 16383, 16384, 1 << 16, 1 << 32, (1 << 63) - 1, 1<<64 - 1}
for _, n := range values {
enc := AppendUvarint(nil, n)
if len(enc) != UvarintLen(n) {
t.Errorf("UvarintLen(%d) = %d, want %d", n, UvarintLen(n), len(enc))
}
got, err := NewDecoder(enc).Uvarint()
if err != nil {
t.Fatalf("decode of %d: %v", n, err)
}
if got != n {
t.Fatalf("round trip of %d gave %d", n, got)
}
}
}
func TestUvarintRejections(t *testing.T) {
cases := []struct {
name string
in []byte
want error
}{
{"empty", nil, ErrTruncated},
{"truncated mid-continuation", []byte{0x80}, ErrTruncated},
{"non-minimal 0x81 0x00", []byte{0x81, 0x00}, ErrNonMinimal},
{"non-minimal two redundant groups", []byte{0xff, 0x81, 0x00}, ErrNonMinimal},
{"overlong ten continuations overflow first", []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01}, ErrOverflow},
{"overflow bit 63 set as 2", []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02}, ErrOverflow},
// 2^64-1 fits in 10 bytes and is the largest accepted value.
{"max value accepted", []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01}, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := NewDecoder(c.in).Uvarint()
if c.want == nil {
if err != nil {
t.Fatalf("want success, got %v", err)
}
return
}
if !errors.Is(err, c.want) {
t.Fatalf("err = %v, want %v", err, c.want)
}
})
}
}
func TestStringRules(t *testing.T) {
valid := []string{
"", "a", "hello world", "NikoCraft",
// U+FEFF is an ordinary character, not a BOM to strip.
"\ufeffbom",
strings.Repeat("a", MaxStringValue),
// Multibyte characters are fine as long as they are not controls.
"café ☕ 中文",
}
for _, s := range valid {
if err := ValidateString(s, MaxStringValue); err != nil {
t.Errorf("ValidateString(%q) = %v, want nil", s, err)
}
}
tooLong := strings.Repeat("a", MaxStringValue+1)
if err := ValidateString(tooLong, MaxStringValue); !errors.Is(err, ErrTooLong) {
t.Errorf("overlong string: err = %v", err)
}
for _, s := range []string{"a\xc3\x28", "\xed\xa0\x80", "a\xff\xfe"} {
if err := ValidateString(s, 100); !errors.Is(err, ErrUTF8) {
t.Errorf("invalid utf8 %q: err = %v", s, err)
}
}
for _, r := range []rune{0x00, 0x1f, 0x7f, 0x80, 0x9f} {
if err := ValidateString(string(r), 100); !errors.Is(err, ErrControlChar) {
t.Errorf("control U+%04X: err = %v", r, err)
}
}
}
func TestKeyGrammar(t *testing.T) {
valid := []string{
"a", "x0", "example.flag", "a.first", "a-b_c.d", "z", strings.Repeat("k", MaxKeyLen),
}
for _, k := range valid {
if err := ValidateKey(k); err != nil {
t.Errorf("ValidateKey(%q) = %v, want nil", k, err)
}
}
invalid := []string{
"A", "1a", ".a", "a.", "a..b", "a--b", "a__b", "a ", "a b", "_a", "-a",
}
for _, k := range invalid {
if err := ValidateKey(k); !errors.Is(err, ErrKeyGrammar) {
t.Errorf("ValidateKey(%q) err = %v, want ErrKeyGrammar", k, err)
}
}
// Empty and over-long keys are also invalid, though they surface as the
// length limit first, which is equally a rejection.
for _, k := range []string{"", strings.Repeat("k", MaxKeyLen+1)} {
if err := ValidateKey(k); err == nil {
t.Errorf("ValidateKey(%q bytes=%d) accepted", k, len(k))
}
}
}
func TestTimestampRange(t *testing.T) {
for _, ts := range []uint64{MinTimestamp, MaxTimestamp, 1_700_000_000} {
if err := ValidateTimestamp(ts, false); err != nil {
t.Errorf("timestamp %d rejected: %v", ts, err)
}
}
for _, ts := range []uint64{0, MinTimestamp - 1, MaxTimestamp + 1} {
if err := ValidateTimestamp(ts, false); !errors.Is(err, ErrTimestamp) {
t.Errorf("timestamp %d accepted", ts)
}
}
if err := ValidateTimestamp(0, true); err != nil {
t.Errorf("zero with allowZero rejected: %v", err)
}
}
func TestEncodeRejectsMalformedField(t *testing.T) {
enc := NewEncoder()
enc.FixedBytes("nonce", make([]byte, 15), NonceSize)
if !errors.Is(enc.Err(), ErrFieldSize) {
t.Fatalf("encoder accepted a short fixed field: %v", enc.Err())
}
enc = NewEncoder()
enc.Identity("id", make([]byte, PubKeySize-1))
if !errors.Is(enc.Err(), ErrFieldSize) {
t.Fatalf("encoder accepted a short identity: %v", enc.Err())
}
enc = NewEncoder()
enc.Timestamp("ts", 0, false)
if !errors.Is(enc.Err(), ErrTimestamp) {
t.Fatalf("encoder accepted zero timestamp: %v", enc.Err())
}
enc = NewEncoder()
enc.Value("v", String("x"))
if err := enc.Err(); err != nil {
t.Fatal(err)
}
}
func TestValueEncoding(t *testing.T) {
cases := []struct {
name string
v Value
want []byte
}{
{"null", Null(), []byte{0x00}},
{"false", Bool(false), []byte{0x01}},
{"true", Bool(true), []byte{0x02}},
{"string", String("hi"), []byte{byte(ValString), 0x02, 'h', 'i'}},
{"number canonicalised", Number("1.0"), []byte{byte(ValNumber), 0x01, '1'}},
{"number preserved as text", Number("42"), []byte{byte(ValNumber), 0x02, '4', '2'}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
b := mustEncode(t, func(e *Encoder) { e.Value("v", c.v) })
if !bytes.Equal(b, c.want) {
t.Fatalf("got %x want %x", b, c.want)
}
})
}
// A number that cannot be canonicalized must fail the encoder.
enc := NewEncoder()
enc.Value("v", Number("1e99999"))
if !errors.Is(enc.Err(), ErrNumberRange) {
t.Fatalf("encoder accepted a non-canonicalisable number: %v", enc.Err())
}
if !Null().Equal(Null()) || !Number("1.0").Equal(Number("1")) || String("a").Equal(String("b")) {
t.Fatal("Value.Equal disagrees")
}
if Bool(false).Equal(Bool(true)) {
t.Fatal("false equals true")
}
}
func TestMapEncodingSortsAndRejects(t *testing.T) {
t.Run("sorted canonical form", func(t *testing.T) {
m := map[string]Value{
"z.last": Null(),
"a.first": Bool(false),
"m.mid": Number("2"),
}
enc := NewEncoder()
enc.Map("claims", m, 1)
b, err := enc.Bytes()
if err != nil {
t.Fatal(err)
}
// 3 entries, keys in alphabetical order.
if !bytes.HasPrefix(b, []byte{0x03, 0x07}) {
t.Fatalf("expected count 3 then len 7 for a.first, got %x", b)
}
dec := NewDecoder(b)
got, err := dec.Map(1)
if err != nil {
t.Fatal(err)
}
// Re-encoding the decoded map reproduces the bytes exactly.
enc2 := NewEncoder()
enc2.Map("claims", got, 1)
b2, err := enc2.Bytes()
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(b, b2) {
t.Fatalf("decode(encode) not stable:\n%x\n%x", b, b2)
}
})
t.Run("duplicate key rejected on decode", func(t *testing.T) {
// count=2, then two entries with the same key length and content.
raw := []byte{0x02, 0x01, 'a', byte(ValTrue), 0x01, 'a', byte(ValFalse)}
if _, err := NewDecoder(raw).Map(1); !errors.Is(err, ErrDuplicateKey) {
t.Fatalf("err = %v, want ErrDuplicateKey", err)
}
})
t.Run("unsorted keys rejected on decode", func(t *testing.T) {
raw := []byte{0x02, 0x01, 'b', byte(ValTrue), 0x01, 'a', byte(ValFalse)}
if _, err := NewDecoder(raw).Map(1); !errors.Is(err, ErrKeyOrder) {
t.Fatalf("err = %v, want ErrKeyOrder", err)
}
})
t.Run("too many entries", func(t *testing.T) {
// Encode 33 entries of a trivial key.
m := make(map[string]Value, MaxMapEntries+1)
for i := 0; i < MaxMapEntries+1; i++ {
m[fmtKey(i)] = Bool(true)
}
enc := NewEncoder()
enc.Map("claims", m, 1)
if !errors.Is(enc.Err(), ErrTooLong) {
t.Fatalf("encoder accepted %d map entries", MaxMapEntries+1)
}
})
t.Run("empty map with minimum required", func(t *testing.T) {
enc := NewEncoder()
enc.Map("claims", nil, 1)
if !errors.Is(enc.Err(), ErrEmptyMap) {
t.Fatalf("encoder accepted an empty map with min 1: %v", enc.Err())
}
})
t.Run("bare key grammar failure on decode", func(t *testing.T) {
raw := []byte{0x01, 0x01, 'A', byte(ValTrue)}
if _, err := NewDecoder(raw).Map(1); !errors.Is(err, ErrKeyGrammar) {
t.Fatalf("err = %v, want ErrKeyGrammar", err)
}
})
t.Run("length prefix exceeds remaining input", func(t *testing.T) {
raw := []byte{0x01, 0x40, 'a'} // claims length 64 but only 1 byte remains
if _, err := NewDecoder(raw).Map(1); !errors.Is(err, ErrTruncated) {
t.Fatalf("err = %v, want ErrTruncated", err)
}
})
}
func fmtKey(i int) string {
return fmt.Sprintf("k%d", i)
}
func TestValueDecoderRejectsReservedTag(t *testing.T) {
for _, tag := range []byte{byte(ValResBytes), byte(ValResArray), byte(ValResMap), 0x08, 0xff} {
d := NewDecoder([]byte{tag})
if _, err := d.Value(); !errors.Is(err, ErrValueTag) {
t.Fatalf("tag 0x%02x accepted", tag)
}
}
// A number in a non-canonical spelling must be rejected, not re-canonicalized.
raw := []byte{byte(ValNumber), 0x03, '1', '.', '0'}
if _, err := NewDecoder(raw).Value(); !errors.Is(err, ErrNumberFormat) {
t.Fatalf("non-canonical number accepted: %v", err)
}
}
func TestFixedLengthDecode(t *testing.T) {
// A correctly sized 16-byte nonce.
raw := []byte{0x10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}
d := NewDecoder(raw)
b, err := d.FixedBytes(NonceSize)
if err != nil {
t.Fatal(err)
}
if len(b) != NonceSize {
t.Fatal("wrong nonce length")
}
// Length prefix claims 8 bytes for a 16-byte field: the prefix is well
// formed and within the remaining input, but the width is wrong.
short := []byte{0x08, 1, 2, 3, 4, 5, 6, 7, 8}
if _, err := NewDecoder(short).FixedBytes(NonceSize); !errors.Is(err, ErrFieldSize) {
t.Fatalf("short fixed field: err = %v, want ErrFieldSize", err)
}
// Length prefix claims more than the remaining input.
over := []byte{0x40, 1}
if _, err := NewDecoder(over).FixedBytes(NonceSize); !errors.Is(err, ErrTruncated) {
t.Fatalf("over-long fixed field accepted: %v", err)
}
}
func TestTrailingBytesAreRejected(t *testing.T) {
good := claimBytes(t)
for _, n := range []int{1, 2} {
trailing := append(append([]byte{}, good...), make([]byte, n)...)
d := NewDecoder(trailing)
if _, err := d.Header(); err != nil {
t.Fatal(err)
}
if _, err := d.Identity(); err != nil {
t.Fatal(err)
}
if _, err := d.Identity(); err != nil {
t.Fatal(err)
}
if _, err := d.Map(1); err != nil {
t.Fatal(err)
}
if _, err := d.Timestamp(false); err != nil {
t.Fatal(err)
}
if _, err := d.Timestamp(true); err != nil {
t.Fatal(err)
}
if _, err := d.Uvarint(); err != nil {
t.Fatal(err)
}
if _, err := d.FixedBytes(NonceSize); err != nil {
t.Fatal(err)
}
if err := d.End(); !errors.Is(err, ErrTrailing) {
t.Fatalf("%d extra bytes: err = %v, want ErrTrailing", n, err)
}
}
}
func TestIdentityFieldRejectsBadAddressVersion(t *testing.T) {
// address version 1 is not defined in v1.
raw := []byte{0x01, 0x20}
raw = append(raw, make([]byte, PubKeySize)...)
if _, err := NewDecoder(raw).Identity(); !errors.Is(err, ErrAddressVersion) {
t.Fatalf("err = %v, want ErrAddressVersion", err)
}
}
func TestObjectID(t *testing.T) {
x := claimBytes(t)
id1 := ComputeID(x)
id2 := ComputeID(append(append([]byte{}, x...), 0))
if id1.Equal(id2) {
t.Fatal("two different byte strings have the same ID")
}
if id1.String() != hex.EncodeToString(id1[:]) {
t.Fatal("ID.String disagrees with hex")
}
parsed, err := ParseID(id1.String())
if err != nil {
t.Fatal(err)
}
if !parsed.Equal(id1) {
t.Fatal("parse round trip failed")
}
for _, bad := range []string{"", "abcd", "ABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCDABCD", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"} {
if _, err := ParseID(bad); err == nil {
t.Fatalf("ParseID accepted %q", bad)
}
}
if _, err := IDFromBytes(make([]byte, 31)); err == nil {
t.Fatal("IDFromBytes accepted 31 bytes")
}
}
func TestInjectiveEncodeDecode(t *testing.T) {
b := claimBytes(t)
// decode then encode must reproduce the bytes exactly, which is the
// non-malleability guarantee of section 12.4.
d := NewDecoder(b)
if _, err := d.Header(); err != nil {
t.Fatal(err)
}
issuer, err := d.Identity()
if err != nil {
t.Fatal(err)
}
subject, err := d.Identity()
if err != nil {
t.Fatal(err)
}
claims, err := d.Map(1)
if err != nil {
t.Fatal(err)
}
createdAt, err := d.Timestamp(false)
if err != nil {
t.Fatal(err)
}
expiresAt, err := d.Timestamp(true)
if err != nil {
t.Fatal(err)
}
serial, err := d.Uvarint()
if err != nil {
t.Fatal(err)
}
nonce, err := d.FixedBytes(NonceSize)
if err != nil {
t.Fatal(err)
}
if err := d.End(); err != nil {
t.Fatal(err)
}
re := mustEncode(t, func(e *Encoder) {
e.Header(TagClaim)
e.Identity("issuer", issuer)
e.Identity("subject", subject)
e.Map("claims", claims, 1)
e.Timestamp("created_at", createdAt, false)
e.Timestamp("expires_at", expiresAt, true)
e.Uvarint(serial)
e.FixedBytes("nonce", nonce, NonceSize)
})
if !bytes.Equal(b, re) {
t.Fatalf("encode(decode(b)) != b:\n%x\n%x", b, re)
}
}

217
internal/tce/tce.go Normal file
View file

@ -0,0 +1,217 @@
// Package tce implements Trust Canonical Encoding version 1.
//
// TCE is the byte-exact representation over which every protocol signature is
// computed. The specification is docs/PROTOCOL.md and the frozen vectors in
// testdata/vectors/tce_vectors.json are normative: this package conforms to
// them, never the other way round.
//
// The central property is that every value has exactly one encoding. The
// encoder emits that encoding, and the decoder accepts only that encoding.
// Anything else, including a longer spelling of the same value, is an error.
// This is what makes a signature over TCE bytes meaningful: two byte strings
// cannot denote the same object, so a signature cannot be transplanted from
// one meaning to another.
//
// The decoder is strict and never repairs input. It does not skip fields it
// does not understand, because a decoder that skipped an unknown field would
// compute a different meaning for the same signed bytes than one that
// understood it, while both saw a valid signature.
//
// This package handles encoding only. It performs no signing, no verification
// and no policy evaluation, and it imports neither encoding/json nor any
// storage or transport package (INV-8, INV-9).
package tce
import (
"errors"
"fmt"
)
// Framing.
const (
// Magic is the domain separation prefix of every TCE object. The trailing
// NUL terminates the ASCII portion so that a longer magic in a future
// version cannot be a prefix of this one.
Magic = "trust.n1ko.dev/tce/1\x00"
// MagicLen is the length of Magic in bytes.
MagicLen = len(Magic)
// Version is the object version encoded by this implementation.
Version = 1
)
// ObjectTag identifies the type of a TCE object. It is part of the signed
// bytes, so a signature over one object type can never be replayed as
// another.
type ObjectTag byte
// Object tags. Tag 0x00 is permanently reserved so that an all-zero buffer is
// never a valid object.
const (
TagReserved ObjectTag = 0x00
TagIdentity ObjectTag = 0x01
TagClaim ObjectTag = 0x02
TagRevocation ObjectTag = 0x03
TagApprovalRequest ObjectTag = 0x04
TagApprovalResponse ObjectTag = 0x05
TagAuthAssertion ObjectTag = 0x06
)
// String renders a tag for diagnostics.
func (t ObjectTag) String() string {
switch t {
case TagIdentity:
return "identity"
case TagClaim:
return "claim"
case TagRevocation:
return "revocation"
case TagApprovalRequest:
return "approval_request"
case TagApprovalResponse:
return "approval_response"
case TagAuthAssertion:
return "auth_assertion"
default:
return fmt.Sprintf("unknown(0x%02x)", byte(t))
}
}
// knownTag reports whether t is a defined object tag in version 1.
func knownTag(t ObjectTag) bool {
switch t {
case TagIdentity, TagClaim, TagRevocation,
TagApprovalRequest, TagApprovalResponse, TagAuthAssertion:
return true
default:
return false
}
}
// ValueTag identifies the type of a claim or payload value.
type ValueTag byte
// Value tags. 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.
//
// Tags 0x05 to 0x07 are reserved for future types and must be rejected in
// version 1 rather than skipped.
const (
ValNull ValueTag = 0x00
ValFalse ValueTag = 0x01
ValTrue ValueTag = 0x02
ValString ValueTag = 0x03
ValNumber ValueTag = 0x04
ValResBytes ValueTag = 0x05
ValResArray ValueTag = 0x06
ValResMap ValueTag = 0x07
)
// Sizes fixed by the specification.
const (
AddressVersion = 0
PubKeySize = 32
NonceSize = 16
HashSize = 32
ChallengeSize = 32
SignatureSize = 64
)
// Field and object limits from PROTOCOL.md section 6.3. These are part of the
// format: an object exceeding any of them is invalid everywhere, so a signer
// cannot produce an object that some verifiers accept and others reject.
const (
MaxUvarintBytes = 10
MaxKeyLen = 128
MaxStringValue = 512
MaxNumberToken = 52
MaxMapEntries = 32
MaxActionLen = 128
MaxMessageLen = 256
MaxReasonLen = 256
MaxAliasLen = 64
MaxScopeLen = 32
MaxAudienceLen = 128
MaxNumberIntDigs = 32
MaxNumberFracDig = 18
MaxNumberSource = 64
MaxIdentityTCE = 1024
MaxClaimTCE = 4096
MaxRevocTCE = 1024
MaxRequestTCE = 8192
MaxResponseTCE = 1024
MaxAuthTCE = 1024
// MaxObjectTCE bounds any object and is used to size read limits before
// the object type is known.
MaxObjectTCE = MaxRequestTCE
)
// Timestamp bounds from PROTOCOL.md section 4.6.
const (
MinTimestamp = 1_000_000_000 // 2001-09-09T01:46:40Z
MaxTimestamp = 4_102_444_800 // 2100-01-01T00:00:00Z
// MaxApprovalLifetime is the largest permitted gap between an approval
// request's created_at and expires_at.
MaxApprovalLifetime = 60
)
// Decision values for an ApprovalResponse.
const (
DecisionDeny = 0
DecisionAllow = 1
)
// Errors returned by this package.
//
// The set is deliberately small and carries no attacker-controlled data, so
// that error text cannot be used to exfiltrate input or to fingerprint a
// parser state machine.
var (
ErrMagic = errors.New("tce: bad magic")
ErrObjectTag = errors.New("tce: unknown object tag")
ErrVersion = errors.New("tce: unsupported object version")
ErrTruncated = errors.New("tce: truncated input")
ErrTrailing = errors.New("tce: trailing bytes after object")
ErrUvarint = errors.New("tce: malformed uvarint")
ErrNonMinimal = errors.New("tce: non-minimal uvarint")
ErrOverflow = errors.New("tce: integer overflow")
ErrTooLong = errors.New("tce: field exceeds maximum length")
ErrObjectTooLarge = errors.New("tce: object exceeds maximum size")
ErrUTF8 = errors.New("tce: invalid UTF-8")
ErrControlChar = errors.New("tce: control character in string")
ErrKeyGrammar = errors.New("tce: map key does not match grammar")
ErrDuplicateKey = errors.New("tce: duplicate map key")
ErrKeyOrder = errors.New("tce: map keys not in ascending order")
ErrValueTag = errors.New("tce: unknown or reserved value tag")
ErrNumberFormat = errors.New("tce: not a valid JSON number")
ErrNumberRange = errors.New("tce: number out of representable range")
ErrTimestamp = errors.New("tce: timestamp out of range")
ErrFieldSize = errors.New("tce: fixed-size field has wrong length")
ErrAddressVersion = errors.New("tce: unsupported address version")
ErrEmptyMap = errors.New("tce: map requires at least one entry")
ErrDecision = errors.New("tce: unknown decision value")
ErrLifetime = errors.New("tce: approval lifetime out of bounds")
ErrExpiry = errors.New("tce: expires_at must be after created_at")
)
// fieldError annotates a sentinel error with the field that failed, without
// including any input data.
type fieldError struct {
field string
err error
}
func (e *fieldError) Error() string { return e.field + ": " + e.err.Error() }
func (e *fieldError) Unwrap() error { return e.err }
func fieldErr(field string, err error) error {
if err == nil {
return nil
}
return &fieldError{field: field, err: err}
}

View file

@ -0,0 +1,2 @@
go test fuzz v1
string("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")

154
internal/tce/value.go Normal file
View file

@ -0,0 +1,154 @@
package tce
// Value is a claim or approval payload value.
//
// The zero Value is null, which is a deliberate choice: a forgotten field
// encodes as an explicit null rather than as something that fails to encode or
// silently disappears.
//
// Value is an opaque struct rather than an interface so that the set of
// representable values is exactly the set the specification defines. There is
// no way to construct a value with a reserved tag, and no way for a caller to
// supply a type the encoder does not know how to canonicalize.
type Value struct {
tag ValueTag
str string // string contents, or the canonical number token
}
// Null returns the null value.
func Null() Value { return Value{tag: ValNull} }
// Bool returns a boolean value.
func Bool(b bool) Value {
if b {
return Value{tag: ValTrue}
}
return Value{tag: ValFalse}
}
// String returns a string value. Validation happens at encode time.
func String(s string) Value { return Value{tag: ValString, str: s} }
// Number returns a numeric value from its exact decimal source token.
//
// The token is taken as text rather than as a float64 because a JSON number
// is an arbitrary-precision decimal literal: converting through a binary float
// loses precision above 2^53 and makes the signed bytes depend on the
// implementation's rounding. Callers decoding JSON should use json.Number,
// which preserves the source token.
//
// The token is canonicalized at encode time, so Number("1.0") and Number("1")
// produce identical bytes.
func Number(token string) Value { return Value{tag: ValNumber, str: token} }
// Int returns a numeric value from an integer.
func Int(n int64) Value { return Value{tag: ValNumber, str: formatInt(n)} }
// Tag returns the value's type tag.
func (v Value) Tag() ValueTag { return v.tag }
// IsNull reports whether v is null.
func (v Value) IsNull() bool { return v.tag == ValNull }
// Bool returns the boolean contents and whether v is a boolean.
func (v Value) Bool() (bool, bool) {
switch v.tag {
case ValTrue:
return true, true
case ValFalse:
return false, true
default:
return false, false
}
}
// Str returns the string contents and whether v is a string.
func (v Value) Str() (string, bool) {
if v.tag == ValString {
return v.str, true
}
return "", false
}
// NumberToken returns the number's decimal token and whether v is a number.
//
// For a decoded value the token is always in canonical form, because the
// decoder rejects any other spelling.
func (v Value) NumberToken() (string, bool) {
if v.tag == ValNumber {
return v.str, true
}
return "", false
}
// Equal reports whether two values are identical after canonicalization.
//
// Numbers compare by canonical form, so Number("1.0") equals Number("1").
// A value that cannot be canonicalized never compares equal to anything,
// including itself, because it has no defined meaning.
func (v Value) Equal(o Value) bool {
if v.tag != o.tag {
return false
}
switch v.tag {
case ValNull, ValFalse, ValTrue:
return true
case ValString:
return v.str == o.str
case ValNumber:
a, err1 := CanonicalNumber(v.str)
b, err2 := CanonicalNumber(o.str)
if err1 != nil || err2 != nil {
return false
}
return a == b
default:
return false
}
}
// GoString renders a value for test failure messages.
func (v Value) GoString() string {
switch v.tag {
case ValNull:
return "null"
case ValTrue:
return "true"
case ValFalse:
return "false"
case ValString:
return "string(" + v.str + ")"
case ValNumber:
return "number(" + v.str + ")"
default:
return "invalid"
}
}
// formatInt renders an int64 in plain decimal without importing strconv's
// float machinery.
func formatInt(n int64) string {
if n == 0 {
return "0"
}
neg := n < 0
var buf [20]byte
i := len(buf)
// Accumulate using uint64 so that math.MinInt64 negates correctly.
var u uint64
if neg {
u = uint64(-(n + 1)) + 1
} else {
u = uint64(n)
}
for u > 0 {
i--
buf[i] = byte('0' + u%10)
u /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}

127
internal/tce/value_test.go Normal file
View file

@ -0,0 +1,127 @@
package tce_test
import (
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
func TestValueConstructorsAndAccessors(t *testing.T) {
// Null.
n := tce.Null()
if !n.IsNull() {
t.Error("Null must report IsNull")
}
if n.Tag() != tce.ValNull {
t.Error("Null tag")
}
if _, ok := n.Bool(); ok {
t.Error("null is not a bool")
}
if _, ok := n.Str(); ok {
t.Error("null is not a string")
}
if _, ok := n.NumberToken(); ok {
t.Error("null is not a number")
}
// Bool.
tr := tce.Bool(true)
fa := tce.Bool(false)
if b, ok := tr.Bool(); !ok || !b {
t.Error("Bool(true)")
}
if b, ok := fa.Bool(); !ok || b {
t.Error("Bool(false)")
}
if tr.Tag() != tce.ValTrue || fa.Tag() != tce.ValFalse {
t.Error("bool tags")
}
// String.
s := tce.String("hello")
if str, ok := s.Str(); !ok || str != "hello" {
t.Error("String")
}
if s.Tag() != tce.ValString {
t.Error("string tag")
}
// Number from token and from int.
nt := tce.Number("1.5")
tok, ok := nt.NumberToken()
if !ok || tok != "1.5" {
t.Error("Number token")
}
ni := tce.Int(42)
if tok, ok := ni.NumberToken(); !ok || tok != "42" {
t.Errorf("Int token = %q, want 42", tok)
}
if ni.Tag() != tce.ValNumber {
t.Error("number tag")
}
// GoString renders for diagnostics.
if tr.GoString() != "true" || fa.GoString() != "false" || n.GoString() != "null" {
t.Error("GoString booleans/null")
}
if s.GoString() != "string(hello)" {
t.Error("GoString string")
}
if nt.GoString() != "number(1.5)" {
t.Error("GoString number")
}
}
func TestValueEqual(t *testing.T) {
if !tce.Null().Equal(tce.Null()) {
t.Error("null == null")
}
if !tce.Bool(true).Equal(tce.Bool(true)) {
t.Error("true == true")
}
if tce.Bool(true).Equal(tce.Bool(false)) {
t.Error("true != false")
}
if !tce.String("a").Equal(tce.String("a")) {
t.Error("string == string")
}
if tce.String("a").Equal(tce.String("b")) {
t.Error("string != string")
}
// Numbers compare by canonical form.
if !tce.Number("1.0").Equal(tce.Number("1")) {
t.Error("1.0 == 1")
}
if !tce.Number("1e1").Equal(tce.Number("10")) {
t.Error("1e1 == 10")
}
if tce.Number("1").Equal(tce.Number("2")) {
t.Error("1 != 2")
}
// Cross-tag never equals.
if tce.String("1").Equal(tce.Number("1")) {
t.Error("string(1) != number(1)")
}
// A number that cannot canonicalize equals nothing, including itself.
bad := tce.Number("0x1")
if bad.Equal(bad) {
t.Error("un-canonicalizable number must equal nothing")
}
}
func TestValueIntConstructor(t *testing.T) {
for _, n := range []int64{0, 1, -1, 42, -42, 123456789, -9223372036854775807, 9223372036854775807} {
tok, ok := tce.Int(n).NumberToken()
if !ok {
t.Fatalf("Int(%d) is not a number", n)
}
c, err := tce.CanonicalNumber(tok)
if err != nil {
t.Fatalf("Int(%d) token not canonical: %v", n, err)
}
if c != tok {
t.Errorf("Int(%d) token %q not already canonical (%q)", n, tok, c)
}
}
}

View file

@ -0,0 +1,94 @@
package tce
import (
"encoding/json"
"os"
"strings"
"testing"
)
// vectorsPath is the location of the frozen vector file relative to the
// package's test working directory.
const vectorsPath = "../../testdata/vectors/tce_vectors.json"
type vectorFile struct {
TceVersion int `json:"tce_version"`
NumberCanonicalization struct {
Accept map[string]string `json:"accept"`
Reject map[string]string `json:"reject"`
} `json:"number_canonicalization"`
}
func loadVectors(t *testing.T) *vectorFile {
t.Helper()
b, err := os.ReadFile(vectorsPath)
if err != nil {
t.Fatalf("read vectors: %v", err)
}
var vf vectorFile
if err := json.Unmarshal(b, &vf); err != nil {
t.Fatalf("parse vectors: %v", err)
}
return &vf
}
// TestNumberCanonicalizationVectors runs the frozen number vectors from the
// reference implementation: every accepted token must canonicalize to exactly
// the reference output, and every rejected token must be refused.
func TestNumberCanonicalizationVectors(t *testing.T) {
vf := loadVectors(t)
accept := vf.NumberCanonicalization.Accept
reject := vf.NumberCanonicalization.Reject
if len(accept) != 28 {
t.Errorf("expect 28 accepted tokens, got %d", len(accept))
}
if len(reject) != 18 {
t.Errorf("expect 18 rejected tokens, got %d", len(reject))
}
for token, want := range accept {
got, err := CanonicalNumber(token)
if err != nil {
t.Errorf("CanonicalNumber(%q): %v", token, err)
continue
}
if got != want {
t.Errorf("CanonicalNumber(%q) = %q, want %q", token, got, want)
}
}
for token, reason := range reject {
if strings.Contains(reason, "ERROR") {
t.Errorf("reference said %q is accepted but it is in the reject list", token)
}
if _, err := CanonicalNumber(token); err == nil {
t.Errorf("CanonicalNumber(%q) accepted, but the reference refuses it (%s)", token, reason)
}
}
}
// TestCanonicalNumberIdempotence pins the §5.1 properties that make the
// encoding canonical: canonicalizing an already canonical token is a no-op,
// and every accepted token yields an accepted, further-canonical result.
func TestCanonicalNumberIdempotence(t *testing.T) {
for _, token := range []string{
"0", "1", "-1", "1.5", "0.001", "12345678901234567890",
"999999999999999999999999", "0.000000000000000001", "-0.000000000000000001",
} {
c, err := CanonicalNumber(token)
if err != nil {
t.Fatalf("CanonicalNumber(%q): %v", token, err)
}
again, err := CanonicalNumber(c)
if err != nil {
t.Fatalf("canonical form %q does not canonicalize: %v", c, err)
}
if again != c {
t.Fatalf("canonical form not fixed point: %q -> %q", c, again)
}
if !IsCanonicalNumber(c) {
t.Fatalf("IsCanonicalNumber(%q) = false", c)
}
}
}

View file

@ -0,0 +1,87 @@
// Package transport defines the JSON wire form of TCE objects, as specified in
// PROTOCOL.md section 10. It is the only layer in the tree that speaks JSON:
// the codec packages (internal/tce, internal/protocol) never import
// encoding/json, because signatures are computed over the binary TCE bytes and
// never over JSON (non-negotiable #8).
//
// A verifier must decode the `tce` field, verify the signature over those exact
// bytes, and read any field it needs from those bytes. The `object` view is a
// convenience only and must never be trusted or re-encoded for verification.
package transport
import (
"encoding/json"
"fmt"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Envelope is the JSON transport form of one signed object.
//
// `tce` and `signature` are authoritative (base64 standard, with padding).
// `object_id` is the lowercase hex of SHA-256(tce); a server must recompute it
// and ignore any supplied value. `object` is a decoded convenience view.
type Envelope struct {
TCE []byte `json:"tce"`
Signature []byte `json:"signature"`
Object json.RawMessage `json:"object,omitempty"`
ObjectID string `json:"object_id,omitempty"`
}
// ParseEnvelope decodes a JSON envelope.
func ParseEnvelope(b []byte) (*Envelope, error) {
var e Envelope
if err := json.Unmarshal(b, &e); err != nil {
return nil, fmt.Errorf("transport: bad envelope: %w", err)
}
return &e, nil
}
// Marshal returns the canonical JSON form of the envelope.
func (e *Envelope) Marshal() ([]byte, error) {
return json.Marshal(e)
}
// ContentID computes the content address of the envelope's TCE bytes (the
// authoritative object_id).
func (e *Envelope) ContentID() string {
return tce.ComputeID(e.TCE).String()
}
// BuildView decodes the TCE bytes and produces the `object` convenience view,
// returning the object type name.
func (e *Envelope) BuildView() (objectType string, view json.RawMessage, err error) {
return BuildView(e.TCE)
}
// Verify performs structural decode and signature verification of the
// envelope, returning the object type name and a verify error if any. Callers
// must treat a non-nil error as total rejection. The signature is checked over
// the exact TCE bytes.
func (e *Envelope) Verify() (objectType string, err error) {
typ, obj, decErr := DecodeObject(e.TCE)
if decErr != nil {
return "", decErr
}
switch obj.(type) {
case *protocol.Identity:
_, err = protocol.VerifyIdentity(e.TCE, e.Signature)
case *protocol.Claim:
_, err = protocol.VerifyClaim(e.TCE, e.Signature)
case *protocol.Revocation:
_, err = protocol.VerifyRevocation(e.TCE, e.Signature)
case *protocol.ApprovalRequest:
_, err = protocol.VerifyApprovalRequest(e.TCE, e.Signature)
case *protocol.ApprovalResponse:
// A response is only meaningful bound to its request; callers verify it
// through VerifyApprovalResponse with the request. Standalone we only
// confirm the response itself is structurally signed.
_, err = protocol.VerifyApprovalResponseStandalone(e.TCE, e.Signature)
case *protocol.AuthAssertion:
// Audience is bound by the server from request context; standalone we
// cannot check it, so we only confirm the assertion is signed.
_, err = protocol.VerifyAuthAssertionSignature(e.TCE, e.Signature)
}
return typ, err
}

View file

@ -0,0 +1,64 @@
package transport_test
import (
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
func TestEnvelopeViewAndVerify(t *testing.T) {
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"example.flag": tce.Bool(true), "level": tce.Number("7")},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
tceBytes, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
sig := issuer.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
// The authoritative object_id is SHA-256(tce).
if env.ContentID() != tce.ComputeID(tceBytes).String() {
t.Fatal("ContentID mismatch")
}
// The object view decodes to valid JSON naming the subject address.
_, view, err := env.BuildView()
if err != nil {
t.Fatal(err)
}
if len(view) == 0 {
t.Fatal("empty view")
}
// A verifier checks the signature over the exact TCE bytes.
if typ, err := env.Verify(); err != nil {
t.Fatalf("verify %s: %v", typ, err)
}
// Round-trips through JSON.
raw, err := env.Marshal()
if err != nil {
t.Fatal(err)
}
back, err := transport.ParseEnvelope(raw)
if err != nil {
t.Fatal(err)
}
if back.ContentID() != env.ContentID() {
t.Fatal("round-trip id mismatch")
}
}

183
internal/transport/view.go Normal file
View file

@ -0,0 +1,183 @@
package transport
import (
"encoding/hex"
"encoding/json"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
func hexStr(b []byte) string { return hex.EncodeToString(b) }
// AddrOf renders a public key as its trust address text.
func AddrOf(pub []byte) string {
id, err := identity.FromPubKey(pub)
if err != nil {
return ""
}
return id.Address().String()
}
func addrOf(pub []byte) string { return AddrOf(pub) }
// valueView renders a TCE value for the JSON `object` convenience view.
func valueView(v tce.Value) any {
switch v.Tag() {
case tce.ValNull:
return nil
case tce.ValTrue:
return true
case tce.ValFalse:
return false
case tce.ValString:
s, _ := v.Str()
return s
case tce.ValNumber:
tok, _ := v.NumberToken()
return json.Number(tok)
}
return nil
}
func objectTypeOf(obj any) string {
switch obj.(type) {
case *protocol.Identity:
return "identity"
case *protocol.Claim:
return "claim"
case *protocol.Revocation:
return "revocation"
case *protocol.ApprovalRequest:
return "request"
case *protocol.ApprovalResponse:
return "response"
case *protocol.AuthAssertion:
return "auth"
}
return ""
}
// ObjectTypeName returns the type name of a decoded protocol object.
func ObjectTypeName(obj any) string { return objectTypeOf(obj) }
// DecodeObject strict-decodes TCE bytes into the typed protocol object and
// returns its type name.
func DecodeObject(b []byte) (string, any, error) {
d := tce.NewDecoder(b)
tag, err := d.Header()
if err != nil {
return "", nil, err
}
switch tag {
case tce.TagIdentity:
o, e := protocol.DecodeIdentity(b)
return "identity", o, e
case tce.TagClaim:
o, e := protocol.DecodeClaim(b)
return "claim", o, e
case tce.TagRevocation:
o, e := protocol.DecodeRevocation(b)
return "revocation", o, e
case tce.TagApprovalRequest:
o, e := protocol.DecodeApprovalRequest(b)
return "request", o, e
case tce.TagApprovalResponse:
o, e := protocol.DecodeApprovalResponse(b)
return "response", o, e
case tce.TagAuthAssertion:
o, e := protocol.DecodeAuthAssertion(b)
return "auth", o, e
default:
return "", nil, tce.ErrObjectTag
}
}
// BuildView decodes the TCE bytes and produces the `object` convenience view of
// PROTOCOL.md section 10. It fails if the bytes do not strict-decode.
func BuildView(b []byte) (objectType string, view json.RawMessage, err error) {
typ, obj, decErr := DecodeObject(b)
if decErr != nil {
return "", nil, decErr
}
var v any
switch o := obj.(type) {
case *protocol.Identity:
v = map[string]any{
"type": "identity",
"version": 1,
"identity": addrOf(o.PubKey),
"alias": o.Alias,
"created_at": o.CreatedAt,
}
case *protocol.Claim:
claims := make(map[string]any, len(o.Claims))
for k, val := range o.Claims {
claims[k] = valueView(val)
}
v = map[string]any{
"type": "claim",
"version": 1,
"issuer": addrOf(o.Issuer),
"subject": addrOf(o.Subject),
"claims": claims,
"created_at": o.CreatedAt,
"expires_at": o.ExpiresAt,
"serial": o.Serial,
"nonce": hexStr(o.Nonce),
}
case *protocol.Revocation:
v = map[string]any{
"type": "revocation",
"version": 1,
"issuer": addrOf(o.Issuer),
"claim_id": o.ClaimID.String(),
"reason": o.Reason,
"created_at": o.CreatedAt,
"nonce": hexStr(o.Nonce),
}
case *protocol.ApprovalRequest:
payload := make(map[string]any, len(o.Payload))
for k, val := range o.Payload {
payload[k] = valueView(val)
}
v = map[string]any{
"type": "request",
"version": 1,
"sender": addrOf(o.Sender),
"recipient": addrOf(o.Recipient),
"action": o.Action,
"payload": payload,
"message": o.Message,
"created_at": o.CreatedAt,
"expires_at": o.ExpiresAt,
"nonce": hexStr(o.Nonce),
}
case *protocol.ApprovalResponse:
v = map[string]any{
"type": "response",
"version": 1,
"request_hash": o.RequestHash.String(),
"responder": addrOf(o.Responder),
"decision": o.Decision.String(),
"created_at": o.CreatedAt,
"nonce": hexStr(o.Nonce),
}
case *protocol.AuthAssertion:
v = map[string]any{
"type": "auth",
"version": 1,
"identity": addrOf(o.PubKey),
"challenge": hexStr(o.Challenge),
"scope": o.Scope,
"audience": o.Audience,
"created_at": o.CreatedAt,
}
}
raw, err := json.Marshal(v)
if err != nil {
return "", nil, err
}
return typ, raw, nil
}

248
internal/verify/verify.go Normal file
View file

@ -0,0 +1,248 @@
// Package verify is the local trust evaluator. It takes signed TCE objects
// (claims, approval requests, approval responses, revocations) that a consumer
// has already fetched from a relay, verifies their signatures, and decides
// whether a subject holds a given attribute at a given time.
//
// The relay answers "who said what" (INV-5); this package answers "should I
// believe it". It never talks to the network and never holds a key. Every
// decision is reproducible from the objects fed in plus an explicit clock.
package verify
import (
"crypto/subtle"
"fmt"
"sync"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/identity"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
// Graph holds the verified objects under evaluation.
type Graph struct {
mu sync.Mutex
claims map[string]*protocol.Claim // claim object id -> claim
claimEnv map[string]*transport.Envelope // claim object id -> envelope
revocations map[string][]*protocol.Revocation // target claim id -> revocations
requests map[string]*protocol.ApprovalRequest
requestEnv map[string]*transport.Envelope
responses map[string]*protocol.ApprovalResponse
responseEnv map[string]*transport.Envelope
}
// NewGraph returns an empty graph.
func NewGraph() *Graph {
return &Graph{
claims: make(map[string]*protocol.Claim),
claimEnv: make(map[string]*transport.Envelope),
revocations: make(map[string][]*protocol.Revocation),
requests: make(map[string]*protocol.ApprovalRequest),
requestEnv: make(map[string]*transport.Envelope),
responses: make(map[string]*protocol.ApprovalResponse),
responseEnv: make(map[string]*transport.Envelope),
}
}
// Add verifies and ingests one envelope. Verification is the signature over the
// exact TCE bytes; a failing object is rejected. The caller is expected to feed
// every object it retrieved, including ones it will later decide are irrelevant.
func (g *Graph) Add(env *transport.Envelope) error {
if _, err := env.Verify(); err != nil {
return fmt.Errorf("verify: %w", err)
}
_, obj, err := transport.DecodeObject(env.TCE)
if err != nil {
return fmt.Errorf("decode: %w", err)
}
id := env.ContentID()
g.mu.Lock()
defer g.mu.Unlock()
switch o := obj.(type) {
case *protocol.Claim:
g.claims[id] = o
g.claimEnv[id] = env
case *protocol.Revocation:
g.revocations[o.ClaimID.String()] = append(g.revocations[o.ClaimID.String()], o)
case *protocol.ApprovalRequest:
g.requests[id] = o
g.requestEnv[id] = env
case *protocol.ApprovalResponse:
g.responses[id] = o
g.responseEnv[id] = env
}
return nil
}
// Policy describes the question put to the evaluator.
type Policy struct {
// Subject is the address the claim must be about.
Subject address.Address
// Predicate is the claim key whose presence/truth is required.
Predicate string
// Approvers, if non-empty, requires an allowance from one of these
// addresses binding to a request sent by the claim's issuer. When empty,
// no approval is required.
Approvers []address.Address
// Threshold is how many distinct approvers must have allowed (k-of-n).
// Zero means "any one" (k = 1).
Threshold int
// Now is the evaluation time.
Now uint64
}
// Result is the evaluator's decision.
type Result struct {
Trusted bool
Claim *protocol.Claim
Issuer address.Address
ApprovedBy address.Address // first approver that allowed (if any)
ApprovedByAll []address.Address // all approvers that allowed
Revoked bool
Reason string
}
// Evaluate decides whether, under the policy, the subject holds the predicate.
func (g *Graph) Evaluate(p Policy) Result {
g.mu.Lock()
defer g.mu.Unlock()
var best *protocol.Claim
var bestID string
for id, c := range g.claims {
if transport.AddrOf(c.Subject) != p.Subject.String() {
continue
}
v, ok := c.Claims[p.Predicate]
if !ok || !valueTruthy(v) {
continue
}
if protocol.ValidateCurrent(c.CreatedAt, c.ExpiresAt, p.Now) != nil {
continue
}
if best == nil || c.Serial > best.Serial {
best = c
bestID = id
}
}
if best == nil {
return Result{Reason: "no active claim for predicate"}
}
// Revocation: a verified revocation must be signed by the claim's issuer and
// target this exact claim.
for _, rev := range g.revocations[bestID] {
if protocol.VerifyRevocationOf(rev, best) == nil {
return Result{Claim: best, Issuer: mustAddr(best.Issuer), Revoked: true, Reason: "revoked: " + rev.Reason}
}
}
issuer := mustAddr(best.Issuer)
// Optional approval requirement.
if len(p.Approvers) > 0 {
first, all, ok := g.findApproval(best, bestID, p)
if !ok {
return Result{Claim: best, Issuer: issuer, Reason: "no valid approval from required approvers"}
}
return Result{Trusted: true, Claim: best, Issuer: issuer, ApprovedBy: first, ApprovedByAll: all}
}
return Result{Trusted: true, Claim: best, Issuer: issuer}
}
// findApproval collects every valid Allow response from the required approvers,
// bound to a request sent by the claim's issuer with Action == predicate and
// fully verified including the request/response binding and timing window. An
// approval is treated as withdrawn if there is a revocation whose target is the
// response's object id, signed by the responder (the approver revoking their
// own decision). It returns the approving addresses; the caller checks the
// count against the threshold (k-of-n).
func (g *Graph) findApproval(claim *protocol.Claim, claimID string, p Policy) (address.Address, []address.Address, bool) {
approved := make([]address.Address, 0)
for rid, resp := range g.responses {
if resp.Decision != protocol.Allow {
continue
}
respAddr := transport.AddrOf(resp.Responder)
if !addrIn(respAddr, p.Approvers) || addrIn(respAddr, approved) {
continue
}
reqID := resp.RequestHash.String()
reqEnv, ok := g.requestEnv[reqID]
if !ok {
continue
}
req, ok := g.requests[reqID]
if !ok {
continue
}
// The request must come from the claim's issuer and name the predicate.
if transport.AddrOf(req.Sender) != transport.AddrOf(claim.Issuer) {
continue
}
if req.Action != p.Predicate {
continue
}
respEnv, ok := g.responseEnv[rid]
if !ok {
continue
}
if _, err := protocol.VerifyApprovalResponse(reqEnv.TCE, reqEnv.Signature, respEnv.TCE, respEnv.Signature); err != nil {
continue
}
// Withdrawal: a revocation targeting this response, signed by the responder.
respID := respEnv.ContentID()
withdrawn := false
for _, rev := range g.revocations[respID] {
if rev.ClaimID.String() == respID && subtle.ConstantTimeCompare(rev.Issuer, resp.Responder) == 1 {
withdrawn = true
break
}
}
if withdrawn {
continue
}
approved = append(approved, addrToAddress(respAddr))
}
need := p.Threshold
if need <= 0 {
need = 1
}
if len(approved) >= need {
return approved[0], approved, true
}
_ = claimID
return address.Address{}, nil, false
}
func valueTruthy(v tce.Value) bool {
if vt, ok := v.Bool(); ok {
return vt
}
return true // non-boolean present values are treated as asserted
}
func addrIn(a string, set []address.Address) bool {
for _, s := range set {
if s.String() == a {
return true
}
}
return false
}
func addrToAddress(s string) address.Address {
a, _ := address.Parse(s)
return a
}
func mustAddr(pub []byte) address.Address {
id, err := identity.FromPubKey(pub)
if err != nil {
return address.Address{}
}
return id.Address()
}

View file

@ -0,0 +1,296 @@
package verify_test
import (
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
"git.n1ko.dev/Niko/niko_trust/internal/verify"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
const base = uint64(1_700_000_000)
func env(t *testing.T, tceBytes, sig []byte) *transport.Envelope {
t.Helper()
return &transport.Envelope{TCE: tceBytes, Signature: sig}
}
func TestTrustedWithoutApproval(t *testing.T) {
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
g := verify.NewGraph()
if err := g.Add(env(t, cb, issuer.Sign(cb))); err != nil {
t.Fatal(err)
}
res := g.Evaluate(verify.Policy{
Subject: subject.Address(),
Predicate: "admin",
Now: base + 10,
})
if !res.Trusted {
t.Fatalf("expected trusted, got %q", res.Reason)
}
if res.Issuer.String() != issuer.Address().String() {
t.Fatal("issuer mismatch")
}
}
func TestRevocationWins(t *testing.T) {
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
id := tce.ComputeID(cb).String()
rv := &protocol.Revocation{
Issuer: issuer.Public(),
ClaimID: tce.ComputeID(cb),
Reason: "mistake",
CreatedAt: base,
Nonce: make([]byte, tce.NonceSize),
}
rvb, _ := protocol.EncodeRevocation(rv)
g := verify.NewGraph()
if err := g.Add(env(t, cb, issuer.Sign(cb))); err != nil {
t.Fatal(err)
}
if err := g.Add(env(t, rvb, issuer.Sign(rvb))); err != nil {
t.Fatal(err)
}
res := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Now: base + 10})
if res.Trusted || !res.Revoked {
t.Fatalf("expected revoked, got trusted=%v revoked=%v (%s)", res.Trusted, res.Revoked, res.Reason)
}
_ = id
}
func TestApprovalRequired(t *testing.T) {
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
// Request from the issuer to the approver.
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
Message: "please approve admin",
CreatedAt: base,
ExpiresAt: base + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
reqID := tce.ComputeID(reqb).String()
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: base + 5,
Nonce: make([]byte, tce.NonceSize),
}
respb, _ := protocol.EncodeApprovalResponse(resp)
approvers := []address.Address{approver.Address()}
g := verify.NewGraph()
g.Add(env(t, cb, issuer.Sign(cb)))
g.Add(env(t, reqb, issuer.Sign(reqb)))
g.Add(env(t, respb, approver.Sign(respb)))
// Without the response the claim is not trusted (approval required).
missing := verify.NewGraph()
missing.Add(env(t, cb, issuer.Sign(cb)))
missing.Add(env(t, reqb, issuer.Sign(reqb)))
r0 := missing.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10})
if r0.Trusted {
t.Fatal("expected untrusted without approval response")
}
// With the response it is trusted and approved by the approver.
r1 := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10})
if !r1.Trusted {
t.Fatalf("expected trusted with approval, got %q", r1.Reason)
}
if r1.ApprovedBy.String() != approver.Address().String() {
t.Fatalf("approved by %s, want %s", r1.ApprovedBy, approver.Address())
}
_ = reqID
_ = time.Now
}
func TestApprovalThreshold(t *testing.T) {
issuer, _ := signer.Generate()
a1, _ := signer.Generate()
a2, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
approvers := []address.Address{a1.Address(), a2.Address()}
// closure returning objects to add
pair := func(approver *signer.Signer) (*transport.Envelope, *transport.Envelope) {
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
CreatedAt: base,
ExpiresAt: base + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: base + 5,
Nonce: make([]byte, tce.NonceSize),
}
respb, _ := protocol.EncodeApprovalResponse(resp)
return env(t, reqb, issuer.Sign(reqb)), env(t, respb, approver.Sign(respb))
}
g := verify.NewGraph()
g.Add(env(t, cb, issuer.Sign(cb)))
r1, rp1 := pair(a1)
g.Add(r1)
g.Add(rp1)
// Only one of two approvers has answered; threshold 2 -> not trusted.
r0 := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Threshold: 2, Now: base + 10})
if r0.Trusted {
t.Fatal("expected untrusted with only one approval at threshold 2")
}
// Both approve -> trusted, both listed.
r2, rp2 := pair(a2)
g.Add(r2)
g.Add(rp2)
r1b := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Threshold: 2, Now: base + 10})
if !r1b.Trusted || len(r1b.ApprovedByAll) != 2 {
t.Fatalf("expected trusted with 2 approvals, got trusted=%v approvers=%d", r1b.Trusted, len(r1b.ApprovedByAll))
}
}
func TestApprovalWithdrawal(t *testing.T) {
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
CreatedAt: base,
ExpiresAt: base + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: base + 5,
Nonce: make([]byte, tce.NonceSize),
}
respb, _ := protocol.EncodeApprovalResponse(resp)
respID := tce.ComputeID(respb).String()
approvers := []address.Address{approver.Address()}
g := verify.NewGraph()
g.Add(env(t, cb, issuer.Sign(cb)))
g.Add(env(t, reqb, issuer.Sign(reqb)))
g.Add(env(t, respb, approver.Sign(respb)))
if r := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10}); !r.Trusted {
t.Fatal("expected trusted before withdrawal")
}
// Approver revokes their own response object.
rv := &protocol.Revocation{
Issuer: approver.Public(),
ClaimID: tce.ComputeID(respb),
Reason: "changed my mind",
CreatedAt: base + 6,
Nonce: make([]byte, tce.NonceSize),
}
rvb, _ := protocol.EncodeRevocation(rv)
g.Add(env(t, rvb, approver.Sign(rvb)))
_ = respID
if r := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10}); r.Trusted {
t.Fatal("expected untrusted after approval withdrawal")
}
}
func TestRejectsBadSignature(t *testing.T) {
issuer, _ := signer.Generate()
impostor, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
g := verify.NewGraph()
if err := g.Add(env(t, cb, impostor.Sign(cb))); err == nil {
t.Fatal("expected Add to reject bad signature")
}
}

313
testdata/vectors/tce_vectors.json vendored Normal file
View file

@ -0,0 +1,313 @@
{
"format": "trust.n1ko.dev TCE test vectors",
"tce_version": 1,
"magic_hex": "74727573742e6e316b6f2e6465762f7463652f3100",
"magic_ascii": "trust.n1ko.dev/tce/1\\x00",
"parties": {
"NikoCraft": {
"seed_hex": "0101010101010101010101010101010101010101010101010101010101010101",
"pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75"
},
"Niko": {
"seed_hex": "0202020202020202020202020202020202020202020202020202020202020202",
"pubkey_hex": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"address": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s"
}
},
"vectors": [
{
"name": "identity/nikocraft",
"description": "Self-asserted identity registration. The alias is signed here and nowhere else.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100010100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c094e696b6f437261667480e2cfaa06",
"tce_len": 72,
"object_id_hex": "6332667904df9f87a66c5f96a6911080ce33044e92e496fc1714f11d644bd833",
"signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"signature_hex": "56d6ab4cffcd91ddab105e2e0f8634fbfcfb6ebf8566056f8eeb1a75d0ccfcab84cc2db03080e3468ce138f56e3f78077253892df9ae9a1f0a03dba4ac86b60a",
"json": {
"type": "identity",
"version": 1,
"identity": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"alias": "NikoCraft",
"created_at": 1700000000
}
},
{
"name": "identity/niko",
"description": "Second identity used as the subject and approver in later vectors.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100010100208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394044e696b6f80e2cfaa06",
"tce_len": 67,
"object_id_hex": "b612147a944ce5f216a4b36638f15b512060bb61114d54e7edab69fdf425d299",
"signer_pubkey_hex": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"signer_address": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"signature_hex": "a9381cc2641c6d4324d1b5c8fd3418f9d1c870e65600a2cef077db3f993690202c66fd2ff0df1637bd95d28b8c3ac283e86305acf8c2011018762bf71ad2660c",
"json": {
"type": "identity",
"version": 1,
"identity": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"alias": "Niko",
"created_at": 1700000000
}
},
{
"name": "claim/boolean",
"description": "Single boolean statement. The server attaches no meaning to the key.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100020100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e0f",
"tce_len": 134,
"object_id_hex": "d3f3e2140658b73214643b95e02dc5a7051207b6f191df940cd38324a06a0796",
"signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"signature_hex": "86311b1f5416cdc869e91777eae2867c96cebd64580eae8dfcce1a72a27546a2b1ed19cd70ddfbbfe09e18c85ee408c94bef5db574b01e8d2b8c57825625df00",
"json": {
"type": "claim",
"version": 1,
"issuer": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"subject": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"claims": {
"example.flag": true
},
"created_at": 1700000000,
"expires_at": 1700086400,
"serial": 1,
"nonce": "000102030405060708090a0b0c0d0e0f"
}
},
{
"name": "claim/all-value-types",
"description": "Every v1 value type in one claim, supplied out of order to exercise key sorting. expires_at 0 means the claim does not expire.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100020100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3940507612e6669727374010d6578616d706c652e6c6576656c040234320c6578616d706c652e72616e6b03055b5649505d0d6578616d706c652e726174696f0403312e35067a2e6c6173740080e2cfaa06000210101112131415161718191a1b1c1d1e1f",
"tce_len": 190,
"object_id_hex": "efd132a9aac2a6e0ba6d51ce7387af6ac69fa75081913f41f04e6b290bf9d2c9",
"signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"signature_hex": "83c9a101d73c2cf80d163e6bc4ffd83e2199bfe889b5fdc3c5d68f41c3c80b53a1e8489d17f718cb186da6f435808aa7d7cadccf2cd96bbb2693eeafac655b01",
"note": "input key order was z.last, example.rank, a.first, example.level, example.ratio; canonical order is a.first, example.level, example.ratio, example.rank, z.last",
"json": {
"type": "claim",
"version": 1,
"issuer": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"subject": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"claims": {
"a.first": false,
"example.level": 42,
"example.ratio": 1.5,
"example.rank": "[VIP]",
"z.last": null
},
"created_at": 1700000000,
"expires_at": 0,
"serial": 2,
"nonce": "101112131415161718191a1b1c1d1e1f"
}
},
{
"name": "revocation/boolean-claim",
"description": "Signed withdrawal of claim/boolean by its issuer.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100030100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c20d3f3e2140658b73214643b95e02dc5a7051207b6f191df940cd38324a06a07960a73757065727365646564e4e2cfaa0610202122232425262728292a2b2c2d2e2f",
"tce_len": 123,
"object_id_hex": "1993a8f417337208e06d83cbf3f823c8925912b119e96e127e4a302137e78457",
"signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"signature_hex": "8e59b92b48209d88b6c28f292593ba1dfc8c1cfbd083790274c82ee7c61eaca06f477b5032efbfa0bd1c82746bd061af740b723610615ae1da306c6ebdcc800d",
"json": {
"type": "revocation",
"version": 1,
"issuer": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"claim_id": "d3f3e2140658b73214643b95e02dc5a7051207b6f191df940cd38324a06a0796",
"reason": "superseded",
"created_at": 1700000100,
"nonce": "202122232425262728292a2b2c2d2e2f"
}
},
{
"name": "approval_request/ban",
"description": "Opaque action with an opaque payload. The relay never interprets either.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100040100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3940b6578616d706c652e62616e0106746172676574030553746576650942616e20537465766580e2cfaa069ee2cfaa0610303132333435363738393a3b3c3d3e3f",
"tce_len": 155,
"object_id_hex": "1a97e752bfa01c77fac8ef20ffd1d8b70ae3d2495f26e62d9b4fd65d2d525fbe",
"signer_pubkey_hex": "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c",
"signer_address": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"signature_hex": "7628fb77a8b54a43d67277a759fa9a7cb2ad9b1e35ae8f87b0f29285a188ab876d73b228f83a1ae2bb54f7bc8e14236a389c893cc44aba82cd81b7b57d00060e",
"json": {
"type": "approval_request",
"version": 1,
"sender": "trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75",
"recipient": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"action": "example.ban",
"payload": {
"target": "Steve"
},
"message": "Ban Steve",
"created_at": 1700000000,
"expires_at": 1700000030,
"nonce": "303132333435363738393a3b3c3d3e3f"
},
"request_id_hex": "1a97e752bfa01c77fac8ef20ffd1d8b70ae3d2495f26e62d9b4fd65d2d525fbe"
},
{
"name": "approval_response/allow",
"description": "Allow decision bound to the exact canonical request bytes.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f31000501201a97e752bfa01c77fac8ef20ffd1d8b70ae3d2495f26e62d9b4fd65d2d525fbe00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394018ae2cfaa0610000102030405060708090a0b0c0d0e0f",
"tce_len": 113,
"object_id_hex": "ac2df7f3ed516ae73498439759911ab212c6bf00d7091993cae0683f1d5a68d4",
"signer_pubkey_hex": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"signer_address": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"signature_hex": "18798d4951092686d848c47ba885f370eeb21736c02163ab781cc6eed07d3baf76095640205ba12e89a83acbf92979cc932bfb9f336150bb947ef9631a744703",
"json": {
"type": "approval_response",
"version": 1,
"request_hash": "1a97e752bfa01c77fac8ef20ffd1d8b70ae3d2495f26e62d9b4fd65d2d525fbe",
"responder": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"decision": "allow",
"created_at": 1700000010,
"nonce": "000102030405060708090a0b0c0d0e0f"
}
},
{
"name": "approval_response/deny",
"description": "Deny decision. Differs from allow in exactly one byte, so a verifier that ignores the decision field is detectable.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f31000501201a97e752bfa01c77fac8ef20ffd1d8b70ae3d2495f26e62d9b4fd65d2d525fbe00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394008ae2cfaa0610000102030405060708090a0b0c0d0e0f",
"tce_len": 113,
"object_id_hex": "fa5021845550155f7df3f27c0aa68d461098c67994270906be51d7296e8b5cbe",
"signer_pubkey_hex": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"signer_address": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"signature_hex": "5b9f26a3b3f9ddbb3d687feb7e4508c116205c8f4435bd9ebe24a984d3c53bcd932e6d28d688aff9cf17c6d7a5c2606cfcb8fc7dca8fcad9afe1943eff851b0b",
"json": {
"type": "approval_response",
"version": 1,
"request_hash": "1a97e752bfa01c77fac8ef20ffd1d8b70ae3d2495f26e62d9b4fd65d2d525fbe",
"responder": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"decision": "deny",
"created_at": 1700000010,
"nonce": "000102030405060708090a0b0c0d0e0f"
}
},
{
"name": "auth_assertion/ws",
"description": "Proof of key possession, bound to one server-issued challenge and to the audience, so it cannot be replayed to another server.",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100060100208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39420a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f900277730e74727573742e6e316b6f2e64657680e2cfaa06",
"tce_len": 113,
"object_id_hex": "db42dfb56ed669d71c9a4990aace4bff03c1e9ea17619a8343261ef3c3102512",
"signer_pubkey_hex": "8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394",
"signer_address": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"signature_hex": "e8a24b337ccf8af34f12b0243b7fe88e7f9c15f58eac38dc84dea38833f8fc72ce5855ae8f0c3b81bb4b5450ce5981273dd7fbde4e20f850f2b07af8ffb9420a",
"json": {
"type": "auth_assertion",
"version": 1,
"identity": "trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s",
"challenge": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"scope": "ws",
"audience": "trust.n1ko.dev",
"created_at": 1700000000
}
}
],
"number_canonicalization": {
"accept": {
"0": "0",
"-0": "0",
"0.0": "0",
"0e10": "0",
"1": "1",
"1.0": "1",
"1e0": "1",
"1.000": "1",
"10": "10",
"1e1": "10",
"100": "100",
"1e2": "100",
"-1": "-1",
"-1.0": "-1",
"1.5": "1.5",
"1.50": "1.5",
"0.1": "0.1",
"1e-1": "0.1",
"0.001": "0.001",
"1e-3": "0.001",
"12345678901234567890": "12345678901234567890",
"1.23e2": "123",
"123": "123",
"-0.5": "-0.5",
"1e18": "1000000000000000000",
"0.000000000000000001": "0.000000000000000001",
"-1e-18": "-0.000000000000000001",
"999999999999999999999999": "999999999999999999999999"
},
"reject": {
"": "number: not a valid JSON number: ''",
"+1": "number: not a valid JSON number: '+1'",
"01": "number: not a valid JSON number: '01'",
"1.": "number: not a valid JSON number: '1.'",
".5": "number: not a valid JSON number: '.5'",
"1e": "number: not a valid JSON number: '1e'",
"1e+": "number: not a valid JSON number: '1e+'",
"--1": "number: not a valid JSON number: '--1'",
"1.2.3": "number: not a valid JSON number: '1.2.3'",
"0x10": "number: not a valid JSON number: '0x10'",
"Infinity": "number: not a valid JSON number: 'Infinity'",
"NaN": "number: not a valid JSON number: 'NaN'",
"1_000": "number: not a valid JSON number: '1_000'",
" 1": "number: not a valid JSON number: ' 1'",
"1 ": "number: not a valid JSON number: '1 '",
"1e99999": "number: exponent has too many digits",
"10000000000000000000000000000000000000000": "number: too many integer digits",
"0.0000000000000000011": "number: too many fractional digits"
}
},
"rejects": [
{
"name": "empty",
"tce_hex": "",
"reason": "no magic"
},
{
"name": "magic_truncated",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f31",
"reason": "magic is incomplete"
},
{
"name": "magic_wrong_version",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3200020100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e0f",
"reason": "framing version is not 1"
},
{
"name": "unknown_object_tag",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f31007f0100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e0f",
"reason": "object tag 0x7f is not defined"
},
{
"name": "object_tag_zero",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100000100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e0f",
"reason": "object tag 0x00 is permanently reserved"
},
{
"name": "truncated_body",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100020100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e",
"reason": "input ends inside the nonce"
},
{
"name": "trailing_byte",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f3100020100208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e0f00",
"reason": "trailing data after the object"
},
{
"name": "non_minimal_uvarint",
"tce_hex": "74727573742e6e316b6f2e6465762f7463652f310002810000208a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5c00208139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b394010c6578616d706c652e666c61670280e2cfaa068085d5aa060110000102030405060708090a0b0c0d0e0f",
"reason": "version encoded as a non-minimal uvarint"
},
{
"name": "unsorted_map_keys",
"reason": "map entries not in ascending bytewise key order",
"note": "constructed by swapping the two entries of a two-key claim"
},
{
"name": "duplicate_map_key",
"reason": "the same key appears twice in one map"
},
{
"name": "reserved_value_tag",
"reason": "value tags 0x05, 0x06 and 0x07 are reserved and must be rejected in v1 rather than skipped"
}
]
}

File diff suppressed because it is too large Load diff