niko_trust/pkg/protocol/verify_test.go
Niko Marmeladkov 3bf13fa488 Public SDK packages, proxy-aware rate limits, service login recipe
- 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
2026-08-26 12:49:54 +03:00

368 lines
12 KiB
Go

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