- 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
368 lines
12 KiB
Go
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)
|
|
}
|
|
}
|