package protocol_test import ( "errors" "testing" "git.n1ko.dev/Niko/niko_trust/pkg/identity/signer" "git.n1ko.dev/Niko/niko_trust/pkg/protocol" "git.n1ko.dev/Niko/niko_trust/pkg/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) } }