niko_trust/internal/protocol/rules_test.go
Niko Marmeladkov 9d66003689
Initial commit: signed-object trust relay, verifier, and docs
- server: relay storing signed objects (PUT/GET), per-IP rate limiting,
  per-subject quota (1000), one-response-per-request, pagination,
  /v1/healthz /v1/readyz /v1/metrics
- verify: signature-verifying trust evaluator; every object is checked via
  env.Verify(), approvals via VerifyApprovalResponse, revocations via
  VerifyRevocationOf; k-of-n approval quorum
- docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and
  the endpoint/status-code contract
- tests: server, verify, and ratelimit packages
2026-08-12 22:36:49 +03:00

394 lines
12 KiB
Go

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()
}