- 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.
396 lines
13 KiB
Go
396 lines
13 KiB
Go
// Package protocol defines the signed objects of the trust protocol and the
|
|
// rules for encoding, decoding and verifying them.
|
|
//
|
|
// Every object here is a statement made by an identity. The protocol
|
|
// establishes who made a statement and that it has not been altered. It never
|
|
// decides whether the statement should be believed or what it means: that is
|
|
// the consuming application's job (INV-5).
|
|
//
|
|
// The package can verify signatures but cannot create them. Signing lives in
|
|
// internal/identity/signer, which server-side code does not import, so a
|
|
// compromised server has no ability to forge anything (INV-1).
|
|
//
|
|
// This package does not import encoding/json. There is exactly one signing
|
|
// representation, and JSON is a transport syntax handled elsewhere
|
|
// (docs/IMPLEMENTATION_NOTES.md property 8).
|
|
package protocol
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
|
)
|
|
|
|
// Errors returned when an object violates a protocol rule.
|
|
var (
|
|
// ErrNil is returned when an encoder or verifier is handed a nil object.
|
|
ErrNil = errors.New("protocol: nil object")
|
|
|
|
// ErrSignature means the Ed25519 signature did not verify over the
|
|
// received canonical bytes.
|
|
ErrSignature = errors.New("protocol: signature does not verify")
|
|
|
|
// ErrSignatureSize means the signature was not 64 bytes.
|
|
ErrSignatureSize = errors.New("protocol: signature must be 64 bytes")
|
|
|
|
// ErrWrongObject means the canonical bytes decoded to a different object
|
|
// type than the caller expected.
|
|
ErrWrongObject = errors.New("protocol: unexpected object type")
|
|
|
|
// ErrRequestMismatch means an approval response does not commit to the
|
|
// request it was presented with.
|
|
ErrRequestMismatch = errors.New("protocol: response does not match request")
|
|
|
|
// ErrWrongResponder means the response was signed by an identity other
|
|
// than the request's recipient.
|
|
ErrWrongResponder = errors.New("protocol: responder is not the request recipient")
|
|
|
|
// ErrResponseTiming means the response is dated outside the request's
|
|
// validity window.
|
|
ErrResponseTiming = errors.New("protocol: response timestamp outside request window")
|
|
|
|
// ErrWrongIssuer means a revocation was signed by someone other than the
|
|
// issuer of the claim it targets.
|
|
ErrWrongIssuer = errors.New("protocol: revocation issuer is not the claim issuer")
|
|
|
|
// ErrWrongClaim means a revocation targets a different claim.
|
|
ErrWrongClaim = errors.New("protocol: revocation does not target this claim")
|
|
|
|
// ErrAudience means an auth assertion was produced for a different
|
|
// server.
|
|
ErrAudience = errors.New("protocol: auth assertion audience mismatch")
|
|
|
|
// ErrEmptyAudience means the caller did not supply an expected audience.
|
|
ErrEmptyAudience = errors.New("protocol: expected audience must not be empty")
|
|
|
|
// ErrExpired means the object's expiry has passed.
|
|
ErrExpired = errors.New("protocol: object has expired")
|
|
|
|
// ErrNotYetValid means the object is dated too far in the future.
|
|
ErrNotYetValid = errors.New("protocol: object created too far in the future")
|
|
|
|
// ErrSelfRevocation means a revocation targets an object that is not a
|
|
// claim.
|
|
ErrSelfRevocation = errors.New("protocol: revocation target is not a claim")
|
|
)
|
|
|
|
// MaxClockSkew is the tolerance applied when comparing a signed timestamp
|
|
// with local time.
|
|
//
|
|
// Timestamps are asserted by the signer, whose clock may differ from the
|
|
// verifier's. Without an allowance, honest objects would be rejected; with too
|
|
// large an allowance, expiry becomes meaningless. See PROTOCOL.md section
|
|
// 13.1.
|
|
const MaxClockSkew = 120
|
|
|
|
// Decision is an approval outcome.
|
|
type Decision uint8
|
|
|
|
// Decision values. There are exactly two; any other encoded value is
|
|
// rejected, so a verifier cannot encounter an outcome it has no rule for.
|
|
const (
|
|
Deny Decision = tce.DecisionDeny
|
|
Allow Decision = tce.DecisionAllow
|
|
)
|
|
|
|
// String renders a decision.
|
|
func (d Decision) String() string {
|
|
switch d {
|
|
case Allow:
|
|
return "allow"
|
|
case Deny:
|
|
return "deny"
|
|
default:
|
|
return "invalid"
|
|
}
|
|
}
|
|
|
|
// Valid reports whether d is a defined decision.
|
|
func (d Decision) Valid() bool { return d == Allow || d == Deny }
|
|
|
|
// ClaimStatus is the lifecycle state of a claim as understood by the protocol
|
|
// layer.
|
|
//
|
|
// There is deliberately no "denied" or "not found" status. Absence of a claim
|
|
// is not a protocol state at all: a relay can withhold anything, so a consumer
|
|
// that treated silence as denial could be manipulated by censorship. What an
|
|
// absent claim means is a decision for the application (INV-5, and
|
|
// docs/IMPLEMENTATION_NOTES.md property 6).
|
|
type ClaimStatus uint8
|
|
|
|
// Claim statuses.
|
|
const (
|
|
// StatusActive means the claim verified and has not expired or been
|
|
// revoked.
|
|
StatusActive ClaimStatus = iota
|
|
|
|
// StatusExpired means the claim's expires_at has passed.
|
|
StatusExpired
|
|
|
|
// StatusRevoked means a verified revocation by the claim's issuer exists.
|
|
StatusRevoked
|
|
)
|
|
|
|
// String renders a status.
|
|
func (s ClaimStatus) String() string {
|
|
switch s {
|
|
case StatusActive:
|
|
return "active"
|
|
case StatusExpired:
|
|
return "expired"
|
|
case StatusRevoked:
|
|
return "revoked"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// Identity is a self-asserted identity registration, object tag 0x01.
|
|
//
|
|
// Registration is a convenience for discovery, not a prerequisite: an identity
|
|
// exists because its key exists.
|
|
//
|
|
// Instances returned by the decode and verify functions carry the canonical
|
|
// bytes they were read from; instances constructed in memory do not.
|
|
type Identity struct {
|
|
// PubKey is the Ed25519 public key that signed this registration.
|
|
PubKey []byte
|
|
|
|
// Alias is a self-asserted display label. It is signed here so that the
|
|
// self-assertion is tamper-evident, but it remains non-authoritative: not
|
|
// unique, not verified, and never consulted when verifying any other
|
|
// object (INV-7). This is the only object in which an alias appears.
|
|
Alias string
|
|
|
|
// CreatedAt is the signer's assertion of when this was made.
|
|
CreatedAt uint64
|
|
|
|
// tce is the canonical bytes this object was decoded from, and sig the
|
|
// signature verified over them. Both are set only by the decode and verify
|
|
// functions. The encoders ignore both and rebuild from the field list, so
|
|
// an encoding can never drift from this package's rules by accident.
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this object was decoded from, or
|
|
// nil for an object constructed in memory. Copies are returned so that a
|
|
// caller cannot alter the byte string a verifier relies on.
|
|
func (o *Identity) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *Identity) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// Claim is a signed statement by an issuer about a subject, object tag 0x02.
|
|
//
|
|
// The meaning of the keys and values is entirely outside the protocol. To
|
|
// every component of the trust system they are opaque strings.
|
|
type Claim struct {
|
|
Issuer []byte
|
|
Subject []byte
|
|
|
|
// Claims holds the statements. At least one entry, at most 32.
|
|
Claims map[string]tce.Value
|
|
|
|
CreatedAt uint64
|
|
|
|
// ExpiresAt is 0 for a claim that does not expire, otherwise a timestamp
|
|
// strictly after CreatedAt.
|
|
ExpiresAt uint64
|
|
|
|
// Serial lets an issuer supersede an earlier claim about the same
|
|
// subject. It is guidance for consumers; no relay enforces an ordering.
|
|
Serial uint64
|
|
|
|
// Nonce makes otherwise identical claims distinct, so that two claims
|
|
// with the same content and timestamp have different object IDs.
|
|
Nonce []byte
|
|
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this claim was decoded from, or
|
|
// nil for a claim constructed in memory.
|
|
func (o *Claim) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *Claim) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// Revocation withdraws a claim, object tag 0x03.
|
|
//
|
|
// It must be signed by the same issuer as the claim it targets. A revocation
|
|
// signed by anyone else is meaningless and is rejected.
|
|
type Revocation struct {
|
|
Issuer []byte
|
|
ClaimID tce.ID
|
|
Reason string
|
|
CreatedAt uint64
|
|
Nonce []byte
|
|
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this revocation was decoded
|
|
// from, or nil for one constructed in memory.
|
|
func (o *Revocation) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *Revocation) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// ApprovalRequest asks a recipient to approve an opaque action, tag 0x04.
|
|
type ApprovalRequest struct {
|
|
Sender []byte
|
|
Recipient []byte
|
|
|
|
// Action and Payload are opaque. Neither the relay nor this package
|
|
// assigns them meaning.
|
|
Action string
|
|
Payload map[string]tce.Value
|
|
|
|
// Message is what a human will read when approving. It is signed, so it
|
|
// cannot be altered in transit, but it is written by the sender: a client
|
|
// must display the sender's address alongside it and must not present it
|
|
// as though the relay endorsed it.
|
|
Message string
|
|
|
|
CreatedAt uint64
|
|
|
|
// ExpiresAt must be after CreatedAt by at most 60 seconds. The bound is
|
|
// part of the format, so an over-long request is invalid everywhere
|
|
// rather than merely refused by one server.
|
|
ExpiresAt uint64
|
|
|
|
Nonce []byte
|
|
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this request was decoded from,
|
|
// or nil for one constructed in memory.
|
|
func (o *ApprovalRequest) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *ApprovalRequest) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// ApprovalResponse is a recipient's signed decision, object tag 0x05.
|
|
type ApprovalResponse struct {
|
|
// RequestHash is the object ID of the exact request being answered. It
|
|
// commits to the full canonical request rather than to a sender-chosen
|
|
// label, which is what makes a signed decision impossible to move to a
|
|
// different request (INV-4).
|
|
RequestHash tce.ID
|
|
|
|
Responder []byte
|
|
Decision Decision
|
|
CreatedAt uint64
|
|
Nonce []byte
|
|
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this response was decoded from,
|
|
// or nil for one constructed in memory.
|
|
func (o *ApprovalResponse) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *ApprovalResponse) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// AuthAssertion proves possession of a private key for one server-issued
|
|
// challenge, object tag 0x06.
|
|
//
|
|
// It is a transport capability only: it authenticates a connection and grants
|
|
// nothing.
|
|
type AuthAssertion struct {
|
|
PubKey []byte
|
|
Challenge []byte
|
|
Scope string
|
|
|
|
// Audience binds the assertion to one server, so that an assertion
|
|
// produced for one relay cannot be replayed to another. Claims and
|
|
// approvals carry no audience because they are global statements meant to
|
|
// be portable between relays.
|
|
Audience string
|
|
CreatedAt uint64
|
|
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this assertion was decoded from,
|
|
// or nil for one constructed in memory.
|
|
func (o *AuthAssertion) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *AuthAssertion) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// DelegationClaim grants another identity the right to speak, object tag
|
|
// 0x07.
|
|
//
|
|
// The granter asserts: "grantee may issue claims whose predicate is one of
|
|
// Predicates, as if I had issued them myself, re-delegating at most MaxDepth
|
|
// further times." Resolution — walking a chain of such objects from a root
|
|
// the verifier already trusts down to a claim's actual issuer — lives in
|
|
// internal/verify. The protocol layer only establishes who granted what to
|
|
// whom and that the grant is intact.
|
|
//
|
|
// A delegation is revoked exactly like a claim: a Revocation signed by the
|
|
// granter targeting this object's ID.
|
|
type DelegationClaim struct {
|
|
// Granter signs this object and lends its authority.
|
|
Granter []byte
|
|
|
|
// Grantee is the identity allowed to speak under the grant.
|
|
Grantee []byte
|
|
|
|
// Predicates are the exact claim keys covered, each with value true.
|
|
// At least one entry, at most 32; prefix or wildcard matching is
|
|
// deliberately not a thing (docs/DELEGATION.md).
|
|
Predicates map[string]tce.Value
|
|
|
|
// MaxDepth is how many further delegation hops may sit below this one.
|
|
// Zero means the grantee speaks for itself only.
|
|
MaxDepth uint64
|
|
|
|
CreatedAt uint64
|
|
|
|
// ExpiresAt is 0 for a grant that does not expire, otherwise strictly
|
|
// after CreatedAt.
|
|
ExpiresAt uint64
|
|
|
|
// Serial lets the granter supersede an earlier grant with new terms.
|
|
Serial uint64
|
|
|
|
Nonce []byte
|
|
|
|
tce []byte
|
|
sig []byte
|
|
}
|
|
|
|
// TCE returns a copy of the canonical bytes this delegation was decoded
|
|
// from, or nil for one constructed in memory.
|
|
func (o *DelegationClaim) TCE() []byte { return bytes.Clone(o.tce) }
|
|
|
|
// Signature returns a copy of the signature verified over the canonical
|
|
// bytes, or nil if none was verified.
|
|
func (o *DelegationClaim) Signature() []byte { return bytes.Clone(o.sig) }
|
|
|
|
// Covers reports whether p is among the delegated predicates.
|
|
func (o *DelegationClaim) Covers(p string) bool {
|
|
v, ok := o.Predicates[p]
|
|
if !ok {
|
|
return false
|
|
}
|
|
b, isBool := v.Bool()
|
|
return isBool && b
|
|
}
|