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:
commit
9d66003689
71 changed files with 14538 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Build artifacts
|
||||
/client
|
||||
/server
|
||||
*.exe
|
||||
*.test
|
||||
|
||||
# Editor / OS noise
|
||||
*.swp
|
||||
.DS_Store
|
||||
129
REPORT_STAGE2B.md
Normal file
129
REPORT_STAGE2B.md
Normal 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
615
cmd/client/main.go
Normal 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
54
cmd/server/main.go
Normal 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
82
docs/ADDRESS.md
Normal 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
95
docs/API.md
Normal 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 |
|
||||
128
docs/IMPLEMENTATION_NOTES.md
Normal file
128
docs/IMPLEMENTATION_NOTES.md
Normal 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
138
docs/INVARIANTS.md
Normal 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
797
docs/PROTOCOL.md
Normal 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^64−1 | — |
|
||||
| length prefix exceeding remaining input | — |
|
||||
| any field exceeding its §6 limit | — |
|
||||
| fixed-width field with the wrong length | — |
|
||||
| map keys not strictly ascending | `unsorted_map_keys` |
|
||||
| duplicate map key | `duplicate_map_key` |
|
||||
| map key not matching the §6.2 grammar | — |
|
||||
| reserved or unknown value tag | `reserved_value_tag` |
|
||||
| invalid UTF-8, or a control character in a string | — |
|
||||
| non-canonical number token | see §5.1 |
|
||||
| timestamp outside the §4.6 range | — |
|
||||
| public key failing curve validation | — |
|
||||
| approval lifetime exceeding 60 s | — |
|
||||
| `decision` other than 0 or 1 | — |
|
||||
|
||||
### 12.4 Re-encoding
|
||||
|
||||
`decode(encode(x)) == x` and `encode(decode(b)) == b` must both hold. The
|
||||
second is the important one: it states that the encoding is not malleable, and
|
||||
that no two byte strings decode to the same object. Both are fuzz properties
|
||||
in Stage 2b.
|
||||
|
||||
---
|
||||
|
||||
## 13. Time, ordering and replay
|
||||
|
||||
### 13.1 Clock skew
|
||||
|
||||
Timestamps come from the signer's clock and cannot be trusted absolutely. A
|
||||
verifier should allow **±120 seconds** of skew when checking whether an object
|
||||
is currently valid, and should reject an object whose `created_at` is further
|
||||
in the future than that allowance.
|
||||
|
||||
### 13.2 Supersession
|
||||
|
||||
`serial` lets an issuer replace an earlier claim about the same subject. For
|
||||
two claims from the same issuer about the same subject with overlapping keys,
|
||||
the one with the higher `serial` is the issuer's later statement.
|
||||
|
||||
This is guidance for consumers, not something a relay enforces. A relay does
|
||||
not decide which of two signed statements is "current"; it stores both. A
|
||||
consumer that needs a single answer applies its own rule, and should treat a
|
||||
missing higher serial as unknown rather than assuming it has seen everything.
|
||||
|
||||
`serial` does not replace revocation. Supersession changes a value;
|
||||
revocation withdraws a statement.
|
||||
|
||||
### 13.3 Nonces and replay
|
||||
|
||||
Every claim, revocation and approval object carries a 16-byte nonce from a
|
||||
CSPRNG. Its purpose is to make otherwise identical objects distinct, so that
|
||||
two claims with the same content and timestamp have different object IDs.
|
||||
|
||||
Replay protection comes from the combination of:
|
||||
|
||||
- the object ID, which is unique per distinct byte string, so a relay can
|
||||
reject a resubmission by ID;
|
||||
- `expires_at`, which bounds how long an object is useful;
|
||||
- the nonce, tracked per issuer within the replay window;
|
||||
- for approvals, the one-response-per-request rule: the first valid response
|
||||
wins and is immutable, so a second signed decision for the same request is
|
||||
rejected rather than overwriting the first.
|
||||
|
||||
A consumer must not assume the relay deduplicated anything. Each of the above
|
||||
checks is cheap and must be applied locally as well.
|
||||
|
||||
---
|
||||
|
||||
## 14. Frozen test vectors
|
||||
|
||||
`testdata/vectors/tce_vectors.json` contains, for each vector: the canonical
|
||||
TCE bytes in hex, the byte length, the SHA-256 object ID, the signer's public
|
||||
key and address, the Ed25519 signature, and the JSON view.
|
||||
|
||||
Two fixed parties are used throughout:
|
||||
|
||||
| Party | Seed | Address |
|
||||
|---|---|---|
|
||||
| NikoCraft | `01` × 32 | `trust1qz9g3c7awsylr90a2tdj6096t4ev5ecfhuwegysm7d6gsqd5pah4c5jya75` |
|
||||
| Niko | `02` × 32 | `trust1qzqnjacw4p73wh6k5d2xds6v0mxvhrv2jx6wudazthmq7ku0exeeg4qse9s` |
|
||||
|
||||
The seeds are deliberately trivial so that any implementation can reproduce
|
||||
the keys. **They are test values and must never be used for anything.**
|
||||
|
||||
The vector set covers:
|
||||
|
||||
| Vector | Exercises |
|
||||
|---|---|
|
||||
| `identity/nikocraft`, `identity/niko` | registration, alias encoding |
|
||||
| `claim/boolean` | minimal claim, single boolean |
|
||||
| `claim/all-value-types` | every value type, map sorting from unsorted input, `expires_at = 0` |
|
||||
| `revocation/boolean-claim` | revocation referencing a claim by content hash |
|
||||
| `approval_request/ban` | opaque action and payload, 30 s lifetime |
|
||||
| `approval_response/allow` | `request_hash` binding |
|
||||
| `approval_response/deny` | differs from allow in one byte, yielding a different ID and signature |
|
||||
| `auth_assertion/ws` | challenge, scope and audience binding |
|
||||
|
||||
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
99
docs/TRUST-MODEL.md
Normal 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
8
go.mod
Normal 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
107
go.sum
Normal 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
286
internal/address/address.go
Normal 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
|
||||
}
|
||||
388
internal/address/address_test.go
Normal file
388
internal/address/address_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
203
internal/address/fuzz_test.go
Normal file
203
internal/address/fuzz_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
30
internal/address/helpers_test.go
Normal file
30
internal/address/helpers_test.go
Normal 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
125
internal/address/pubkey.go
Normal 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
|
||||
}
|
||||
148
internal/address/pubkey_test.go
Normal file
148
internal/address/pubkey_test.go
Normal 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
136
internal/identity/alias.go
Normal 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:]
|
||||
}
|
||||
186
internal/identity/alias_test.go
Normal file
186
internal/identity/alias_test.go
Normal 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
|
||||
}
|
||||
124
internal/identity/fuzz_test.go
Normal file
124
internal/identity/fuzz_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
150
internal/identity/identity.go
Normal file
150
internal/identity/identity.go
Normal 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
|
||||
}
|
||||
346
internal/identity/identity_test.go
Normal file
346
internal/identity/identity_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
207
internal/identity/invariants_test.go
Normal file
207
internal/identity/invariants_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
182
internal/identity/signer/signer.go
Normal file
182
internal/identity/signer/signer.go
Normal 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
|
||||
}
|
||||
12
internal/identity/testhelpers_test.go
Normal file
12
internal/identity/testhelpers_test.go
Normal 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
|
||||
}
|
||||
180
internal/protocol/accessors_test.go
Normal file
180
internal/protocol/accessors_test.go
Normal 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
339
internal/protocol/decode.go
Normal 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
209
internal/protocol/encode.go
Normal 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)
|
||||
}
|
||||
89
internal/protocol/example_test.go
Normal file
89
internal/protocol/example_test.go
Normal 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
50
internal/protocol/fuzz.go
Normal 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
|
||||
}
|
||||
166
internal/protocol/fuzz_test.go
Normal file
166
internal/protocol/fuzz_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
118
internal/protocol/helpers_test.go
Normal file
118
internal/protocol/helpers_test.go
Normal 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
|
||||
}
|
||||
98
internal/protocol/invariants_test.go
Normal file
98
internal/protocol/invariants_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
130
internal/protocol/mutation_test.go
Normal file
130
internal/protocol/mutation_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
335
internal/protocol/objects.go
Normal file
335
internal/protocol/objects.go
Normal 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) }
|
||||
110
internal/protocol/rejects_test.go
Normal file
110
internal/protocol/rejects_test.go
Normal 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")
|
||||
}
|
||||
394
internal/protocol/rules_test.go
Normal file
394
internal/protocol/rules_test.go
Normal 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()
|
||||
}
|
||||
238
internal/protocol/vectors_test.go
Normal file
238
internal/protocol/vectors_test.go
Normal 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
295
internal/protocol/verify.go
Normal 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
|
||||
}
|
||||
368
internal/protocol/verify_test.go
Normal file
368
internal/protocol/verify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
internal/server/invariants_test.go
Normal file
42
internal/server/invariants_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
internal/server/metrics.go
Normal file
42
internal/server/metrics.go
Normal 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())
|
||||
}
|
||||
60
internal/server/ratelimit.go
Normal file
60
internal/server/ratelimit.go
Normal 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
|
||||
}
|
||||
60
internal/server/ratelimit_test.go
Normal file
60
internal/server/ratelimit_test.go
Normal 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
434
internal/server/server.go
Normal 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})
|
||||
}
|
||||
884
internal/server/server_test.go
Normal file
884
internal/server/server_test.go
Normal 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
288
internal/server/store.go
Normal 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
287
internal/tce/decoder.go
Normal 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
309
internal/tce/encoder.go
Normal 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
33
internal/tce/fuzz.go
Normal 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
107
internal/tce/fuzz_test.go
Normal 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
94
internal/tce/id.go
Normal 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
114
internal/tce/id_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
110
internal/tce/invariants_test.go
Normal file
110
internal/tce/invariants_test.go
Normal 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
242
internal/tce/number.go
Normal 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
120
internal/tce/number_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
580
internal/tce/primitives_test.go
Normal file
580
internal/tce/primitives_test.go
Normal 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
217
internal/tce/tce.go
Normal 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}
|
||||
}
|
||||
2
internal/tce/testdata/fuzz/FuzzStringValidation/f921751fe02821d6
vendored
Normal file
2
internal/tce/testdata/fuzz/FuzzStringValidation/f921751fe02821d6
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
go test fuzz v1
|
||||
string("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000")
|
||||
154
internal/tce/value.go
Normal file
154
internal/tce/value.go
Normal 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
127
internal/tce/value_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
internal/tce/vectors_test.go
Normal file
94
internal/tce/vectors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
87
internal/transport/envelope.go
Normal file
87
internal/transport/envelope.go
Normal 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
|
||||
}
|
||||
64
internal/transport/envelope_test.go
Normal file
64
internal/transport/envelope_test.go
Normal 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
183
internal/transport/view.go
Normal 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
248
internal/verify/verify.go
Normal 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()
|
||||
}
|
||||
296
internal/verify/verify_test.go
Normal file
296
internal/verify/verify_test.go
Normal 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
313
testdata/vectors/tce_vectors.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
tools/reference/__pycache__/tce_reference.cpython-314.pyc
Normal file
BIN
tools/reference/__pycache__/tce_reference.cpython-314.pyc
Normal file
Binary file not shown.
1044
tools/reference/tce_reference.py
Normal file
1044
tools/reference/tce_reference.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue