niko_trust/pkg/verify/rotation_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

188 lines
5.1 KiB
Go

package verify_test
import (
"bytes"
"testing"
"git.n1ko.dev/Niko/niko_trust/pkg/address"
"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"
"git.n1ko.dev/Niko/niko_trust/pkg/verify"
)
// rotationFixture stores both halves of a rotation link.
type rotationFixture struct {
oldKey *signer.Signer
newKey *signer.Signer
subj *signer.Signer
g *verify.Graph
}
func newRotationFixture(t *testing.T) *rotationFixture {
t.Helper()
return &rotationFixture{
oldKey: mustSigner(t),
newKey: mustSigner(t),
subj: mustSigner(t),
g: verify.NewGraph(),
}
}
func (f *rotationFixture) addRotation(t *testing.T, old, new *signer.Signer,
createdAt uint64, lifetime int64, confAt uint64, nonce byte) {
t.Helper()
req := &protocol.KeyRotationRequest{
Successor: new.Public(),
Predecessor: old.Public(),
CreatedAt: createdAt,
ExpiresAt: createdAt + uint64(lifetime),
}
rb, err := protocol.EncodeKeyRotationRequest(req)
if err != nil {
t.Fatal(err)
}
rsig := new.Sign(rb)
if err := f.g.Add(env(t, rb, rsig)); err != nil {
t.Fatal(err)
}
conf := &protocol.KeyRotationConfirm{
RotationHash: tce.ComputeID(rb),
CreatedAt: confAt,
Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize),
}
cb, err := protocol.EncodeKeyRotationConfirm(conf)
if err != nil {
t.Fatal(err)
}
if err := f.g.Add(env(t, cb, old.Sign(cb))); err != nil {
t.Fatal(err)
}
}
func (f *rotationFixture) addClaimBy(t *testing.T, key *signer.Signer) {
c := &protocol.Claim{
Issuer: key.Public(),
Subject: f.subj.Public(),
Claims: map[string]tce.Value{"mod": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x41}, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
_ = f.g.Add(env(t, cb, key.Sign(cb)))
}
func (f *rotationFixture) evaluate(now uint64, maxAge uint64) verify.Result {
rootAddr := f.oldKey.Identity().Address()
return f.g.Evaluate(verify.Policy{
Subject: f.subj.Address(),
Predicate: "mod",
TrustedIssuers: []address.Address{rootAddr},
RotationMaxAge: maxAge,
Now: now,
})
}
func TestRotationSingleHop(t *testing.T) {
f := newRotationFixture(t)
f.addRotation(t, f.oldKey, f.newKey, base-30, 60, base-20, 0x51)
f.addClaimBy(t, f.newKey)
res := f.evaluate(base+10, 0)
if !res.Trusted {
t.Fatalf("rotated issuer rejected: %q", res.Reason)
}
if len(res.RotationChain) != 2 ||
res.RotationChain[0].String() != f.newKey.Identity().Address().String() ||
res.RotationChain[1].String() != f.oldKey.Identity().Address().String() {
t.Fatalf("rotation chain = %v", res.RotationChain)
}
}
func TestRotationTwoHops(t *testing.T) {
f := newRotationFixture(t)
mid := mustSigner(t)
// root(old) → mid → new
f.addRotation(t, f.oldKey, mid, base-60, 60, base-50, 0x52)
f.addRotation(t, mid, f.newKey, base-40, 60, base-30, 0x53)
f.addClaimBy(t, f.newKey)
res := f.evaluate(base+10, 0)
if !res.Trusted || len(res.RotationChain) != 3 {
t.Fatalf("two-hop rotation failed: %+v", res)
}
}
func TestRotationWithoutConfirmFails(t *testing.T) {
f := newRotationFixture(t)
req := &protocol.KeyRotationRequest{
Successor: f.newKey.Public(),
Predecessor: f.oldKey.Public(),
CreatedAt: base - 30,
ExpiresAt: base + 30,
}
rb, _ := protocol.EncodeKeyRotationRequest(req)
_ = f.g.Add(env(t, rb, f.newKey.Sign(rb)))
f.addClaimBy(t, f.newKey)
if res := f.evaluate(base+10, 0); res.Trusted {
t.Fatal("unconfirmed succession accepted")
}
}
func TestRotationWrongConfirmerFails(t *testing.T) {
f := newRotationFixture(t)
impostor := mustSigner(t)
req := &protocol.KeyRotationRequest{
Successor: f.newKey.Public(),
Predecessor: f.oldKey.Public(),
CreatedAt: base - 30,
ExpiresAt: base + 30,
}
rb, _ := protocol.EncodeKeyRotationRequest(req)
_ = f.g.Add(env(t, rb, f.newKey.Sign(rb)))
conf := &protocol.KeyRotationConfirm{
RotationHash: tce.ComputeID(rb),
CreatedAt: base - 10,
Nonce: bytes.Repeat([]byte{0x54}, tce.NonceSize),
}
cb, _ := protocol.EncodeKeyRotationConfirm(conf)
_ = f.g.Add(env(t, cb, impostor.Sign(cb))) // not the predecessor
f.addClaimBy(t, f.newKey)
if res := f.evaluate(base+10, 0); res.Trusted {
t.Fatal("consent from a stranger accepted")
}
}
func TestRotationStaleLinkFailsWithMaxAge(t *testing.T) {
f := newRotationFixture(t)
f.addRotation(t, f.oldKey, f.newKey, base-500, 60, base-490, 0x55)
f.addClaimBy(t, f.newKey)
// Fresh enough without a limit.
if res := f.evaluate(base+10, 0); !res.Trusted {
t.Fatalf("no-limit policy rejected fresh history: %q", res.Reason)
}
// Stale once freshness is demanded: confirm is ~500s old, limit 300s.
if res := f.evaluate(base+10, 300); res.Trusted {
t.Fatal("stale rotation accepted under RotationMaxAge")
}
}
func TestRotationWindowEnforced(t *testing.T) {
f := newRotationFixture(t)
// Confirm dated after the request expired by more than the skew.
f.addRotation(t, f.oldKey, f.newKey, base-100, 10, base+200, 0x56)
f.addClaimBy(t, f.newKey)
if res := f.evaluate(base+250, 0); res.Trusted {
t.Fatal("confirm outside the request window accepted")
}
}