- 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
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package verify_test
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/transport"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/verify"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/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)
|
|
}
|
|
}
|