- 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
533 lines
17 KiB
Go
533 lines
17 KiB
Go
// Package verify is the local trust evaluator. It takes signed TCE objects
|
|
// (claims, approval requests, approval responses, revocations) that a consumer
|
|
// has already fetched from a relay, verifies their signatures, and decides
|
|
// whether a subject holds a given attribute at a given time.
|
|
//
|
|
// The relay answers "who said what" (INV-5); this package answers "should I
|
|
// believe it". It never talks to the network and never holds a key. Every
|
|
// decision is reproducible from the objects fed in plus an explicit clock.
|
|
package verify
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/address"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/identity"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/protocol"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/tce"
|
|
"git.n1ko.dev/Niko/niko_trust/pkg/transport"
|
|
)
|
|
|
|
// Graph holds the verified objects under evaluation.
|
|
type Graph struct {
|
|
mu sync.Mutex
|
|
|
|
claims map[string]*protocol.Claim // claim object id -> claim
|
|
claimEnv map[string]*transport.Envelope // claim object id -> envelope
|
|
delegations map[string]*protocol.DelegationClaim // delegation id -> grant
|
|
delegEnv map[string]*transport.Envelope // delegation id -> envelope
|
|
rotRequests map[string]*protocol.KeyRotationRequest // request id -> request
|
|
rotConfirms map[string][]*rotationConfirm // request id -> confirms
|
|
rotEnv map[string]*transport.Envelope // request/confirm id -> envelope
|
|
revocations map[string][]*protocol.Revocation // target claim id -> revocations
|
|
requests map[string]*protocol.ApprovalRequest
|
|
requestEnv map[string]*transport.Envelope
|
|
responses map[string]*protocol.ApprovalResponse
|
|
responseEnv map[string]*transport.Envelope
|
|
}
|
|
|
|
// rotationConfirm pairs a stored confirm with the content id it was filed
|
|
// under, so ties between competing consents resolve deterministically.
|
|
type rotationConfirm struct {
|
|
id string
|
|
c *protocol.KeyRotationConfirm
|
|
env *transport.Envelope
|
|
}
|
|
|
|
// NewGraph returns an empty graph.
|
|
func NewGraph() *Graph {
|
|
return &Graph{
|
|
claims: make(map[string]*protocol.Claim),
|
|
claimEnv: make(map[string]*transport.Envelope),
|
|
delegations: make(map[string]*protocol.DelegationClaim),
|
|
delegEnv: make(map[string]*transport.Envelope),
|
|
rotRequests: make(map[string]*protocol.KeyRotationRequest),
|
|
rotConfirms: make(map[string][]*rotationConfirm),
|
|
rotEnv: make(map[string]*transport.Envelope),
|
|
revocations: make(map[string][]*protocol.Revocation),
|
|
requests: make(map[string]*protocol.ApprovalRequest),
|
|
requestEnv: make(map[string]*transport.Envelope),
|
|
responses: make(map[string]*protocol.ApprovalResponse),
|
|
responseEnv: make(map[string]*transport.Envelope),
|
|
}
|
|
}
|
|
|
|
// Add verifies and ingests one envelope. Verification is the signature over the
|
|
// exact TCE bytes; a failing object is rejected. The caller is expected to feed
|
|
// every object it retrieved, including ones it will later decide are irrelevant.
|
|
func (g *Graph) Add(env *transport.Envelope) error {
|
|
if _, err := env.Verify(); err != nil {
|
|
return fmt.Errorf("verify: %w", err)
|
|
}
|
|
_, obj, err := transport.DecodeObject(env.TCE)
|
|
if err != nil {
|
|
return fmt.Errorf("decode: %w", err)
|
|
}
|
|
id := env.ContentID()
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
switch o := obj.(type) {
|
|
case *protocol.Claim:
|
|
g.claims[id] = o
|
|
g.claimEnv[id] = env
|
|
case *protocol.DelegationClaim:
|
|
g.delegations[id] = o
|
|
g.delegEnv[id] = env
|
|
case *protocol.KeyRotationRequest:
|
|
g.rotRequests[id] = o
|
|
g.rotEnv[id] = env
|
|
case *protocol.KeyRotationConfirm:
|
|
g.rotConfirms[o.RotationHash.String()] = append(
|
|
g.rotConfirms[o.RotationHash.String()],
|
|
&rotationConfirm{id: id, c: o, env: env})
|
|
case *protocol.Revocation:
|
|
g.revocations[o.ClaimID.String()] = append(g.revocations[o.ClaimID.String()], o)
|
|
case *protocol.ApprovalRequest:
|
|
g.requests[id] = o
|
|
g.requestEnv[id] = env
|
|
case *protocol.ApprovalResponse:
|
|
g.responses[id] = o
|
|
g.responseEnv[id] = env
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Policy describes the question put to the evaluator.
|
|
type Policy struct {
|
|
// Subject is the address the claim must be about.
|
|
Subject address.Address
|
|
// Predicate is the claim key whose presence/truth is required.
|
|
Predicate string
|
|
// Approvers, if non-empty, requires an allowance from one of these
|
|
// addresses binding to a request sent by the claim's issuer. When empty,
|
|
// no approval is required.
|
|
Approvers []address.Address
|
|
// Threshold is how many distinct approvers must have allowed (k-of-n).
|
|
// Zero means "any one" (k = 1).
|
|
Threshold int
|
|
// TrustedIssuers restricts which issuers may satisfy the policy. Empty
|
|
// means "any issuer the caller fed into the graph", preserving the
|
|
// original model where anchoring is fully out of band. A non-empty set
|
|
// is a pure filter: claims from other issuers are ignored, never
|
|
// treated as negative evidence.
|
|
TrustedIssuers []address.Address
|
|
// MaxDepth caps delegation chain hops when TrustedIssuers is set. Zero
|
|
// means the built-in default (3). It also bounds key-rotation chains.
|
|
MaxDepth int
|
|
// RotationMaxAge is how many seconds old a key-rotation confirm may be
|
|
// at the evaluation instant and still count. Zero disables the check:
|
|
// a confirmed rotation never goes stale. The bound exists because a
|
|
// stolen predecessor key could otherwise rotate silently forever.
|
|
RotationMaxAge uint64
|
|
// Now is the evaluation time.
|
|
Now uint64
|
|
}
|
|
|
|
// Result is the evaluator's decision.
|
|
type Result struct {
|
|
Trusted bool
|
|
Claim *protocol.Claim
|
|
Issuer address.Address
|
|
ApprovedBy address.Address // first approver that allowed (if any)
|
|
ApprovedByAll []address.Address // all approvers that allowed
|
|
Revoked bool
|
|
// Chain is the delegation path issuer → … → trusted root when the
|
|
// claim's authority came from one; nil for direct issuance. It is
|
|
// audit evidence, not an authorization statement by itself.
|
|
Chain []address.Address
|
|
// RotationChain is the key-succession path issuer → … → root when the
|
|
// claim's issuer reached a trusted root through confirmed rotations.
|
|
// Also audit evidence only.
|
|
RotationChain []address.Address
|
|
Reason string
|
|
}
|
|
|
|
// Evaluate decides whether, under the policy, the subject holds the predicate.
|
|
func (g *Graph) Evaluate(p Policy) Result {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
|
|
// Collect every structurally valid candidate, then decide trust per
|
|
// candidate in a deterministic order: highest serial first, ties broken
|
|
// by content ID. Determinism matters now that acceptance may require a
|
|
// delegation walk — map iteration order must never leak into decisions.
|
|
type candidate struct {
|
|
id string
|
|
c *protocol.Claim
|
|
}
|
|
var candidates []candidate
|
|
for id, c := range g.claims {
|
|
if transport.AddrOf(c.Subject) != p.Subject.String() {
|
|
continue
|
|
}
|
|
v, ok := c.Claims[p.Predicate]
|
|
if !ok || !valueTruthy(v) {
|
|
continue
|
|
}
|
|
if protocol.ValidateCurrent(c.CreatedAt, c.ExpiresAt, p.Now) != nil {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate{id, c})
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
a, b := candidates[i], candidates[j]
|
|
if a.c.Serial != b.c.Serial {
|
|
return a.c.Serial > b.c.Serial
|
|
}
|
|
return a.id < b.id
|
|
})
|
|
|
|
for _, cand := range candidates {
|
|
issuer := mustAddr(cand.c.Issuer)
|
|
|
|
// Trust anchoring: an empty filter keeps the original model (the
|
|
// caller fed only objects it cares about). A non-empty filter is
|
|
// satisfied either directly or through a delegation chain from a
|
|
// trusted root down to the claim's actual issuer.
|
|
var chain, rotChain []address.Address
|
|
if len(p.TrustedIssuers) > 0 && !addrInAddr(issuer, p.TrustedIssuers) {
|
|
resolved, ok := g.resolveDelegationChain(cand.c.Issuer, p)
|
|
if !ok {
|
|
rotChain, ok = g.resolveRotationChain(cand.c.Issuer, p)
|
|
}
|
|
if !ok {
|
|
continue // this issuer cannot speak; try the next claim
|
|
}
|
|
chain = resolved
|
|
}
|
|
|
|
// Revocation: a verified revocation must be signed by the claim's
|
|
// issuer and target this exact claim.
|
|
for _, rev := range g.revocations[cand.id] {
|
|
if protocol.VerifyRevocationOf(rev, cand.c) == nil {
|
|
return Result{Claim: cand.c, Issuer: issuer, Chain: chain,
|
|
RotationChain: rotChain,
|
|
Revoked: true, Reason: "revoked: " + rev.Reason}
|
|
}
|
|
}
|
|
|
|
// Optional approval requirement.
|
|
if len(p.Approvers) > 0 {
|
|
first, all, ok := g.findApproval(cand.c, cand.id, p)
|
|
if !ok {
|
|
return Result{Claim: cand.c, Issuer: issuer, Chain: chain,
|
|
RotationChain: rotChain,
|
|
Reason: "no valid approval from required approvers"}
|
|
}
|
|
return Result{Trusted: true, Claim: cand.c, Issuer: issuer, Chain: chain,
|
|
RotationChain: rotChain, ApprovedBy: first, ApprovedByAll: all}
|
|
}
|
|
|
|
return Result{Trusted: true, Claim: cand.c, Issuer: issuer, Chain: chain,
|
|
RotationChain: rotChain}
|
|
}
|
|
return Result{Reason: "no active claim for predicate"}
|
|
}
|
|
|
|
// defaultMaxDepth bounds delegation chains when the policy does not say.
|
|
const defaultMaxDepth = 3
|
|
|
|
// resolveDelegationChain searches for a path of active, unrevoked, covering
|
|
// delegations from the claim's issuer up to any trusted root, honouring each
|
|
// link's own re-delegation budget. It returns the chain ordered issuer → …
|
|
// → root (inclusive of both ends).
|
|
func (g *Graph) resolveDelegationChain(issuerPub []byte, p Policy) ([]address.Address, bool) {
|
|
maxDepth := p.MaxDepth
|
|
if maxDepth <= 0 {
|
|
maxDepth = defaultMaxDepth
|
|
}
|
|
|
|
origin := append([]byte(nil), issuerPub...)
|
|
current := append([]byte(nil), issuerPub...)
|
|
chain := []address.Address{mustAddr(origin)}
|
|
|
|
for hop := 1; hop <= maxDepth; hop++ {
|
|
// Pick the strongest grant to current: highest serial, then lowest
|
|
// content id, so the outcome never depends on map order.
|
|
bestID := ""
|
|
var best *protocol.DelegationClaim
|
|
for id, d := range g.delegations {
|
|
if subtle.ConstantTimeCompare(d.Grantee, current) != 1 {
|
|
continue
|
|
}
|
|
if !d.Covers(p.Predicate) {
|
|
continue
|
|
}
|
|
if protocol.DelegationStatusAt(d, p.Now) != protocol.StatusActive {
|
|
continue
|
|
}
|
|
// The granter revokes its own grant by object id.
|
|
withdrawn := false
|
|
for _, rev := range g.revocations[id] {
|
|
if protocol.VerifyRevocationOfDelegation(rev, d) == nil {
|
|
withdrawn = true
|
|
break
|
|
}
|
|
}
|
|
if withdrawn {
|
|
continue
|
|
}
|
|
if best == nil || d.Serial > best.Serial ||
|
|
(d.Serial == best.Serial && id < bestID) {
|
|
best, bestID = d, id
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil, false
|
|
}
|
|
root := mustAddr(best.Granter)
|
|
chain = append(chain, root)
|
|
if addrInAddr(root, p.TrustedIssuers) {
|
|
// Every link must have allowed the hops that sit below it:
|
|
// link i of k needs MaxDepth >= k-i.
|
|
k := len(chain) - 1
|
|
links := g.collectLinks(origin, p, k)
|
|
if len(links) != k {
|
|
return nil, false
|
|
}
|
|
// Link i has k-1-i hops strictly below it; each must fit in
|
|
// that link's own budget.
|
|
for i, d := range links {
|
|
if uint64(k-1-i) > d.MaxDepth {
|
|
return nil, false
|
|
}
|
|
}
|
|
return chain, true
|
|
}
|
|
current = best.Granter
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// collectLinks replays the chosen chain deterministically, returning the
|
|
// delegation objects in issuer→root order for depth-budget checking.
|
|
func (g *Graph) collectLinks(issuerPub []byte, p Policy, maxHops int) []*protocol.DelegationClaim {
|
|
current := append([]byte(nil), issuerPub...)
|
|
var links []*protocol.DelegationClaim
|
|
for hop := 0; hop < maxHops; hop++ {
|
|
bestID := ""
|
|
var best *protocol.DelegationClaim
|
|
for id, d := range g.delegations {
|
|
if subtle.ConstantTimeCompare(d.Grantee, current) != 1 || !d.Covers(p.Predicate) {
|
|
continue
|
|
}
|
|
if protocol.DelegationStatusAt(d, p.Now) != protocol.StatusActive {
|
|
continue
|
|
}
|
|
withdrawn := false
|
|
for _, rev := range g.revocations[id] {
|
|
if protocol.VerifyRevocationOfDelegation(rev, d) == nil {
|
|
withdrawn = true
|
|
break
|
|
}
|
|
}
|
|
if withdrawn {
|
|
continue
|
|
}
|
|
if best == nil || d.Serial > best.Serial ||
|
|
(d.Serial == best.Serial && id < bestID) {
|
|
best, bestID = d, id
|
|
}
|
|
}
|
|
if best == nil {
|
|
return nil
|
|
}
|
|
links = append(links, best)
|
|
if addrInAddr(mustAddr(best.Granter), p.TrustedIssuers) {
|
|
return links
|
|
}
|
|
current = best.Granter
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findApproval collects every valid Allow response from the required approvers,
|
|
// bound to a request sent by the claim's issuer with Action == predicate and
|
|
// fully verified including the request/response binding and timing window. An
|
|
// approval is treated as withdrawn if there is a revocation whose target is the
|
|
// response's object id, signed by the responder (the approver revoking their
|
|
// own decision). It returns the approving addresses; the caller checks the
|
|
// count against the threshold (k-of-n).
|
|
func (g *Graph) findApproval(claim *protocol.Claim, claimID string, p Policy) (address.Address, []address.Address, bool) {
|
|
approved := make([]address.Address, 0)
|
|
for rid, resp := range g.responses {
|
|
if resp.Decision != protocol.Allow {
|
|
continue
|
|
}
|
|
respAddr := transport.AddrOf(resp.Responder)
|
|
if !addrIn(respAddr, p.Approvers) || addrIn(respAddr, approved) {
|
|
continue
|
|
}
|
|
reqID := resp.RequestHash.String()
|
|
reqEnv, ok := g.requestEnv[reqID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
req, ok := g.requests[reqID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
// The request must come from the claim's issuer and name the predicate.
|
|
if transport.AddrOf(req.Sender) != transport.AddrOf(claim.Issuer) {
|
|
continue
|
|
}
|
|
if req.Action != p.Predicate {
|
|
continue
|
|
}
|
|
respEnv, ok := g.responseEnv[rid]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if _, err := protocol.VerifyApprovalResponse(reqEnv.TCE, reqEnv.Signature, respEnv.TCE, respEnv.Signature); err != nil {
|
|
continue
|
|
}
|
|
// Withdrawal: a revocation targeting this response, signed by the responder.
|
|
respID := respEnv.ContentID()
|
|
withdrawn := false
|
|
for _, rev := range g.revocations[respID] {
|
|
if rev.ClaimID.String() == respID && subtle.ConstantTimeCompare(rev.Issuer, resp.Responder) == 1 {
|
|
withdrawn = true
|
|
break
|
|
}
|
|
}
|
|
if withdrawn {
|
|
continue
|
|
}
|
|
approved = append(approved, addrToAddress(respAddr))
|
|
}
|
|
need := p.Threshold
|
|
if need <= 0 {
|
|
need = 1
|
|
}
|
|
if len(approved) >= need {
|
|
return approved[0], approved, true
|
|
}
|
|
_ = claimID
|
|
return address.Address{}, nil, false
|
|
}
|
|
|
|
func valueTruthy(v tce.Value) bool {
|
|
if vt, ok := v.Bool(); ok {
|
|
return vt
|
|
}
|
|
return true // non-boolean present values are treated as asserted
|
|
}
|
|
|
|
func addrIn(a string, set []address.Address) bool {
|
|
for _, s := range set {
|
|
if s.String() == a {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func addrInAddr(a address.Address, set []address.Address) bool {
|
|
for _, s := range set {
|
|
if s.Equal(a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func addrToAddress(s string) address.Address {
|
|
a, _ := address.Parse(s)
|
|
return a
|
|
}
|
|
|
|
func mustAddr(pub []byte) address.Address {
|
|
id, err := identity.FromPubKey(pub)
|
|
if err != nil {
|
|
return address.Address{}
|
|
}
|
|
return id.Address()
|
|
}
|
|
|
|
// defaultMaxRotations bounds key-succession chains when the policy is silent.
|
|
const defaultMaxRotations = 4
|
|
|
|
// resolveRotationChain searches for a sequence of confirmed rotations from
|
|
// the claim's issuer up to a trusted root. Every link must be fully verified
|
|
// (both signatures plus the hash binding), inside its request window, fresh
|
|
// under Policy.RotationMaxAge when set, and unambiguous — competing confirms
|
|
// for one request make that link unusable rather than picking a winner.
|
|
func (g *Graph) resolveRotationChain(issuerPub []byte, p Policy) ([]address.Address, bool) {
|
|
maxHops := p.MaxDepth
|
|
if maxHops <= 0 {
|
|
maxHops = defaultMaxRotations
|
|
}
|
|
|
|
current := append([]byte(nil), issuerPub...)
|
|
chain := []address.Address{mustAddr(current)}
|
|
|
|
for hop := 0; hop < maxHops; hop++ {
|
|
reqID := g.findConfirmedRequest(current, p)
|
|
if reqID == "" {
|
|
return nil, false
|
|
}
|
|
req := g.rotRequests[reqID]
|
|
root := mustAddr(req.Predecessor)
|
|
chain = append(chain, root)
|
|
if addrInAddr(root, p.TrustedIssuers) {
|
|
return chain, true
|
|
}
|
|
current = append([]byte(nil), req.Predecessor...)
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// findConfirmedRequest returns the id of the newest valid request whose
|
|
// successor is current, or "" when none qualifies. Freshness under
|
|
// Policy.RotationMaxAge is enforced on the confirm's own timestamp.
|
|
func (g *Graph) findConfirmedRequest(successorPub []byte, p Policy) string {
|
|
bestReqID := ""
|
|
var bestConf *rotationConfirm
|
|
for reqID, req := range g.rotRequests {
|
|
if subtle.ConstantTimeCompare(req.Successor, successorPub) != 1 {
|
|
continue
|
|
}
|
|
confirms := g.rotConfirms[reqID]
|
|
if len(confirms) != 1 {
|
|
continue // no consent, or equivocation: unusable either way
|
|
}
|
|
conf := confirms[0]
|
|
|
|
// Full binding check against stored bytes.
|
|
reqEnv := g.rotEnv[reqID]
|
|
if _, err := protocol.VerifyKeyRotationConfirm(
|
|
reqEnv.TCE, reqEnv.Signature, conf.env.TCE, conf.env.Signature); err != nil {
|
|
continue
|
|
}
|
|
if !protocol.RotationWindowOK(req, conf.c) {
|
|
continue
|
|
}
|
|
if p.RotationMaxAge > 0 {
|
|
if conf.c.CreatedAt > p.Now && conf.c.CreatedAt-p.Now > protocol.MaxClockSkew {
|
|
continue // dated too far in the future: hostile clock
|
|
}
|
|
if p.Now > conf.c.CreatedAt && p.Now-conf.c.CreatedAt > p.RotationMaxAge+protocol.MaxClockSkew {
|
|
continue // stale: the link no longer counts as recent consent
|
|
}
|
|
}
|
|
if bestConf == nil || conf.id < bestConf.id {
|
|
// Newest request wins on ties of confirm ids; requests carry no
|
|
// serial, so content order is the deterministic fallback.
|
|
bestReqID, bestConf = reqID, conf
|
|
}
|
|
}
|
|
return bestReqID
|
|
}
|