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