niko_trust/internal/verify/verify_test.go
Niko Marmeladkov 20cc52c3a5 feat: network layer — PoW, checkpoint chain, gossip, light node, WS, delegation, rotation, BFT
- BLAKE3 keyed proof-of-work on object storage and auth challenges,
  with frozen vectors cross-checked against an independent Python
  reference implementing the single-block hash it needs.
- Sparse Merkle trie over object IDs: order-independent roots,
  inclusion and absence proofs (internal/smt).
- Signed checkpoint chain per relay: transport key amendment to INV-1,
  /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe
  epoch continuity (internal/checkpoint).
- Head gossip with TOFU pinning and equivocation detection; light node
  (cmd/lightnode) that stores no history: quorum of pinned relays,
  every served object proven against the agreed root, LRU disk cache.
- WebSocket streaming on relay and light node (coder/websocket):
  scoped channels mirroring REST, raw envelopes verified client-side;
  light node marks streamed objects unproven until checkpoint coverage.
- Protocol v1 additions: DelegationClaim tag 0x07 with deterministic
  chain resolution in verify.Graph, KeyRotationRequest/Confirm tags
  0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge;
  spec sections, frozen vectors appended byte-identically, Python
  reference extended.
- Optional permissioned BFT finality over gossip (internal/bft):
  prevote/precommit with quorum certificates verifiable offline.
- Quick wins: Policy.TrustedIssuers, per-type stored metrics,
  batch fetch, lexicographic lists with stable cursor pagination.
- Security review of the network layer (docs/SECURITY-REVIEW.md) with
  findings F-01..F-09; hub send/close race and unstable pagination
  fixed under review.

12 packages green, vet/gofmt clean, protocol fuzzing stable.
2026-08-25 20:38:40 +03:00

344 lines
10 KiB
Go

package verify_test
import (
"bytes"
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
"git.n1ko.dev/Niko/niko_trust/internal/verify"
"git.n1ko.dev/Niko/niko_trust/internal/address"
)
const base = uint64(1_700_000_000)
func env(t *testing.T, tceBytes, sig []byte) *transport.Envelope {
t.Helper()
return &transport.Envelope{TCE: tceBytes, Signature: sig}
}
func TestTrustedWithoutApproval(t *testing.T) {
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
g := verify.NewGraph()
if err := g.Add(env(t, cb, issuer.Sign(cb))); err != nil {
t.Fatal(err)
}
res := g.Evaluate(verify.Policy{
Subject: subject.Address(),
Predicate: "admin",
Now: base + 10,
})
if !res.Trusted {
t.Fatalf("expected trusted, got %q", res.Reason)
}
if res.Issuer.String() != issuer.Address().String() {
t.Fatal("issuer mismatch")
}
}
func TestRevocationWins(t *testing.T) {
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
id := tce.ComputeID(cb).String()
rv := &protocol.Revocation{
Issuer: issuer.Public(),
ClaimID: tce.ComputeID(cb),
Reason: "mistake",
CreatedAt: base,
Nonce: make([]byte, tce.NonceSize),
}
rvb, _ := protocol.EncodeRevocation(rv)
g := verify.NewGraph()
if err := g.Add(env(t, cb, issuer.Sign(cb))); err != nil {
t.Fatal(err)
}
if err := g.Add(env(t, rvb, issuer.Sign(rvb))); err != nil {
t.Fatal(err)
}
res := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Now: base + 10})
if res.Trusted || !res.Revoked {
t.Fatalf("expected revoked, got trusted=%v revoked=%v (%s)", res.Trusted, res.Revoked, res.Reason)
}
_ = id
}
func TestApprovalRequired(t *testing.T) {
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
// Request from the issuer to the approver.
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
Message: "please approve admin",
CreatedAt: base,
ExpiresAt: base + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
reqID := tce.ComputeID(reqb).String()
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: base + 5,
Nonce: make([]byte, tce.NonceSize),
}
respb, _ := protocol.EncodeApprovalResponse(resp)
approvers := []address.Address{approver.Address()}
g := verify.NewGraph()
g.Add(env(t, cb, issuer.Sign(cb)))
g.Add(env(t, reqb, issuer.Sign(reqb)))
g.Add(env(t, respb, approver.Sign(respb)))
// Without the response the claim is not trusted (approval required).
missing := verify.NewGraph()
missing.Add(env(t, cb, issuer.Sign(cb)))
missing.Add(env(t, reqb, issuer.Sign(reqb)))
r0 := missing.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10})
if r0.Trusted {
t.Fatal("expected untrusted without approval response")
}
// With the response it is trusted and approved by the approver.
r1 := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10})
if !r1.Trusted {
t.Fatalf("expected trusted with approval, got %q", r1.Reason)
}
if r1.ApprovedBy.String() != approver.Address().String() {
t.Fatalf("approved by %s, want %s", r1.ApprovedBy, approver.Address())
}
_ = reqID
_ = time.Now
}
func TestApprovalThreshold(t *testing.T) {
issuer, _ := signer.Generate()
a1, _ := signer.Generate()
a2, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
approvers := []address.Address{a1.Address(), a2.Address()}
// closure returning objects to add
pair := func(approver *signer.Signer) (*transport.Envelope, *transport.Envelope) {
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
CreatedAt: base,
ExpiresAt: base + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: base + 5,
Nonce: make([]byte, tce.NonceSize),
}
respb, _ := protocol.EncodeApprovalResponse(resp)
return env(t, reqb, issuer.Sign(reqb)), env(t, respb, approver.Sign(respb))
}
g := verify.NewGraph()
g.Add(env(t, cb, issuer.Sign(cb)))
r1, rp1 := pair(a1)
g.Add(r1)
g.Add(rp1)
// Only one of two approvers has answered; threshold 2 -> not trusted.
r0 := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Threshold: 2, Now: base + 10})
if r0.Trusted {
t.Fatal("expected untrusted with only one approval at threshold 2")
}
// Both approve -> trusted, both listed.
r2, rp2 := pair(a2)
g.Add(r2)
g.Add(rp2)
r1b := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Threshold: 2, Now: base + 10})
if !r1b.Trusted || len(r1b.ApprovedByAll) != 2 {
t.Fatalf("expected trusted with 2 approvals, got trusted=%v approvers=%d", r1b.Trusted, len(r1b.ApprovedByAll))
}
}
func TestApprovalWithdrawal(t *testing.T) {
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
CreatedAt: base,
ExpiresAt: base + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: base + 5,
Nonce: make([]byte, tce.NonceSize),
}
respb, _ := protocol.EncodeApprovalResponse(resp)
respID := tce.ComputeID(respb).String()
approvers := []address.Address{approver.Address()}
g := verify.NewGraph()
g.Add(env(t, cb, issuer.Sign(cb)))
g.Add(env(t, reqb, issuer.Sign(reqb)))
g.Add(env(t, respb, approver.Sign(respb)))
if r := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10}); !r.Trusted {
t.Fatal("expected trusted before withdrawal")
}
// Approver revokes their own response object.
rv := &protocol.Revocation{
Issuer: approver.Public(),
ClaimID: tce.ComputeID(respb),
Reason: "changed my mind",
CreatedAt: base + 6,
Nonce: make([]byte, tce.NonceSize),
}
rvb, _ := protocol.EncodeRevocation(rv)
g.Add(env(t, rvb, approver.Sign(rvb)))
_ = respID
if r := g.Evaluate(verify.Policy{Subject: subject.Address(), Predicate: "admin", Approvers: approvers, Now: base + 10}); r.Trusted {
t.Fatal("expected untrusted after approval withdrawal")
}
}
func TestRejectsBadSignature(t *testing.T) {
issuer, _ := signer.Generate()
impostor, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
g := verify.NewGraph()
if err := g.Add(env(t, cb, impostor.Sign(cb))); err == nil {
t.Fatal("expected Add to reject bad signature")
}
}
func TestTrustedIssuersFilter(t *testing.T) {
good, _ := signer.Generate()
bad, _ := signer.Generate()
subject, _ := signer.Generate()
g := verify.NewGraph()
addClaim := func(issuer *signer.Signer, nonce byte) {
t.Helper()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"mod": tce.Bool(true)},
CreatedAt: base - 100,
Serial: 1,
Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
if err := g.Add(env(t, cb, issuer.Sign(cb))); err != nil {
t.Fatal(err)
}
}
addClaim(bad, 0x11)
// With the filter, the untrusted claim does not satisfy the policy.
res := g.Evaluate(verify.Policy{
Subject: subject.Address(),
Predicate: "mod",
Now: base + 10,
TrustedIssuers: []address.Address{good.Address()},
})
if res.Trusted || res.Issuer.String() != "" && res.Issuer.IsZero() == false {
t.Fatalf("untrusted issuer accepted: %+v", res)
}
// The same claim satisfies once its issuer is trusted.
addClaim(good, 0x12)
res2 := g.Evaluate(verify.Policy{
Subject: subject.Address(),
Predicate: "mod",
Now: base + 10,
TrustedIssuers: []address.Address{good.Address()},
})
if !res2.Trusted || res2.Issuer.String() != good.Address().String() {
t.Fatalf("trusted issuer rejected: %q", res2.Reason)
}
}