- internal/{address,identity,protocol,tce,transport,verify} -> pkg/ so
external Go projects can import the verified core; invariant tests
updated for the new paths
- Config.TrustProxy: key rate limiting by X-Forwarded-For when the relay
sits behind a reverse proxy (off by default, header never trusted
otherwise)
- examples/service + examples/approve: complete passwordless login round
trip (mint request -> wallet approves -> local verify), run live in CI
- docs/SERVICE-GUIDE.md: the integration recipe
394 lines
12 KiB
Go
394 lines
12 KiB
Go
package protocol_test
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/protocol"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/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()
|
|
}
|