- 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.
412 lines
10 KiB
Go
412 lines
10 KiB
Go
package protocol
|
|
|
|
import (
|
|
"bytes"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/internal/address"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
|
)
|
|
|
|
// Decoders for the six protocol objects.
|
|
//
|
|
// Each decoder reads the fields in the exact order PROTOCOL.md section 8
|
|
// lists them and enforces the whole-object size limit before touching the
|
|
// body. Identity public keys are validated as canonical curve points here,
|
|
// which is the step crypto/ed25519.Verify deliberately does not perform and
|
|
// therefore the one that must happen before any signature check
|
|
// (docs/PROTOCOL.md section 7.3).
|
|
//
|
|
// Every byte slice a decoder exposes is a copy: keys, nonces and the retained
|
|
// canonical bytes never alias the caller's buffer, so a decoded object cannot
|
|
// be altered after the fact by mutating the input.
|
|
|
|
// decodeIdentityField reads an identity field and validates its public key.
|
|
func decodeIdentityField(d *tce.Decoder, name string) ([]byte, error) {
|
|
pub, err := d.Identity()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := address.ValidatePubKey(pub); err != nil {
|
|
return nil, fieldErr(name, err)
|
|
}
|
|
return pub, nil
|
|
}
|
|
|
|
// checkEnd asserts the object consumed the whole input: any byte after the
|
|
// last field would give the object a different ID and signature from the
|
|
// object it appears to contain.
|
|
func checkEnd(d *tce.Decoder) error {
|
|
return d.End()
|
|
}
|
|
|
|
// DecodeIdentity parses an IdentityRegistration from its canonical bytes.
|
|
func DecodeIdentity(b []byte) (*Identity, error) {
|
|
if len(b) > tce.MaxIdentityTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagIdentity {
|
|
return nil, ErrWrongObject
|
|
}
|
|
pub, err := decodeIdentityField(d, "identity")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
alias, err := d.String(tce.MaxAliasLen)
|
|
if err != nil {
|
|
return nil, fieldErr("alias", err)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &Identity{PubKey: pub, Alias: alias, CreatedAt: createdAt}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|
|
|
|
// DecodeClaim parses a Claim from its canonical bytes.
|
|
func DecodeClaim(b []byte) (*Claim, error) {
|
|
if len(b) > tce.MaxClaimTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagClaim {
|
|
return nil, ErrWrongObject
|
|
}
|
|
issuer, err := decodeIdentityField(d, "issuer")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
subject, err := decodeIdentityField(d, "subject")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
claims, err := d.Map(1)
|
|
if err != nil {
|
|
return nil, fieldErr("claims", err)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
expiresAt, err := d.Timestamp(true)
|
|
if err != nil {
|
|
return nil, fieldErr("expires_at", err)
|
|
}
|
|
if expiresAt != 0 && expiresAt <= createdAt {
|
|
return nil, fieldErr("expires_at", tce.ErrExpiry)
|
|
}
|
|
serial, err := d.Uvarint()
|
|
if err != nil {
|
|
return nil, fieldErr("serial", err)
|
|
}
|
|
nonce, err := d.FixedBytes(tce.NonceSize)
|
|
if err != nil {
|
|
return nil, fieldErr("nonce", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &Claim{
|
|
Issuer: issuer, Subject: subject, Claims: claims,
|
|
CreatedAt: createdAt, ExpiresAt: expiresAt, Serial: serial,
|
|
Nonce: bytes.Clone(nonce),
|
|
}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|
|
|
|
// DecodeRevocation parses a Revocation from its canonical bytes.
|
|
func DecodeRevocation(b []byte) (*Revocation, error) {
|
|
if len(b) > tce.MaxRevocTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagRevocation {
|
|
return nil, ErrWrongObject
|
|
}
|
|
issuer, err := decodeIdentityField(d, "issuer")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
claimHash, err := d.FixedBytes(tce.HashSize)
|
|
if err != nil {
|
|
return nil, fieldErr("claim_id", err)
|
|
}
|
|
claimID, err := tce.IDFromBytes(claimHash)
|
|
if err != nil {
|
|
return nil, fieldErr("claim_id", err)
|
|
}
|
|
reason, err := d.String(tce.MaxReasonLen)
|
|
if err != nil {
|
|
return nil, fieldErr("reason", err)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
nonce, err := d.FixedBytes(tce.NonceSize)
|
|
if err != nil {
|
|
return nil, fieldErr("nonce", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &Revocation{
|
|
Issuer: issuer, ClaimID: claimID, Reason: reason,
|
|
CreatedAt: createdAt, Nonce: bytes.Clone(nonce),
|
|
}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|
|
|
|
// DecodeApprovalRequest parses an ApprovalRequest from its canonical bytes.
|
|
func DecodeApprovalRequest(b []byte) (*ApprovalRequest, error) {
|
|
if len(b) > tce.MaxRequestTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagApprovalRequest {
|
|
return nil, ErrWrongObject
|
|
}
|
|
sender, err := decodeIdentityField(d, "sender")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
recipient, err := decodeIdentityField(d, "recipient")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
action, err := d.String(tce.MaxActionLen)
|
|
if err != nil {
|
|
return nil, fieldErr("action", err)
|
|
}
|
|
payload, err := d.Map(0)
|
|
if err != nil {
|
|
return nil, fieldErr("payload", err)
|
|
}
|
|
message, err := d.String(tce.MaxMessageLen)
|
|
if err != nil {
|
|
return nil, fieldErr("message", err)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
expiresAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("expires_at", err)
|
|
}
|
|
if expiresAt <= createdAt {
|
|
return nil, fieldErr("expires_at", tce.ErrExpiry)
|
|
}
|
|
if expiresAt-createdAt > tce.MaxApprovalLifetime {
|
|
return nil, fieldErr("expires_at", tce.ErrLifetime)
|
|
}
|
|
nonce, err := d.FixedBytes(tce.NonceSize)
|
|
if err != nil {
|
|
return nil, fieldErr("nonce", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &ApprovalRequest{
|
|
Sender: sender, Recipient: recipient, Action: action,
|
|
Payload: payload, Message: message, CreatedAt: createdAt,
|
|
ExpiresAt: expiresAt, Nonce: bytes.Clone(nonce),
|
|
}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|
|
|
|
// DecodeApprovalResponse parses an ApprovalResponse from its canonical bytes.
|
|
//
|
|
// This only parses. Binding the response to a particular request is the job
|
|
// of VerifyApprovalResponse, which requires the request.
|
|
func DecodeApprovalResponse(b []byte) (*ApprovalResponse, error) {
|
|
if len(b) > tce.MaxResponseTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagApprovalResponse {
|
|
return nil, ErrWrongObject
|
|
}
|
|
hashBytes, err := d.FixedBytes(tce.HashSize)
|
|
if err != nil {
|
|
return nil, fieldErr("request_hash", err)
|
|
}
|
|
requestHash, err := tce.IDFromBytes(hashBytes)
|
|
if err != nil {
|
|
return nil, fieldErr("request_hash", err)
|
|
}
|
|
responder, err := decodeIdentityField(d, "responder")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decisionRaw, err := d.Uvarint()
|
|
if err != nil {
|
|
return nil, fieldErr("decision", err)
|
|
}
|
|
if Decision(decisionRaw) != Deny && Decision(decisionRaw) != Allow {
|
|
return nil, fieldErr("decision", tce.ErrDecision)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
nonce, err := d.FixedBytes(tce.NonceSize)
|
|
if err != nil {
|
|
return nil, fieldErr("nonce", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &ApprovalResponse{
|
|
RequestHash: requestHash, Responder: responder,
|
|
Decision: Decision(decisionRaw), CreatedAt: createdAt,
|
|
Nonce: bytes.Clone(nonce),
|
|
}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|
|
|
|
// DecodeAuthAssertion parses an AuthAssertion from its canonical bytes.
|
|
func DecodeAuthAssertion(b []byte) (*AuthAssertion, error) {
|
|
if len(b) > tce.MaxAuthTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagAuthAssertion {
|
|
return nil, ErrWrongObject
|
|
}
|
|
pub, err := decodeIdentityField(d, "identity")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
challenge, err := d.FixedBytes(tce.ChallengeSize)
|
|
if err != nil {
|
|
return nil, fieldErr("challenge", err)
|
|
}
|
|
scope, err := d.String(tce.MaxScopeLen)
|
|
if err != nil {
|
|
return nil, fieldErr("scope", err)
|
|
}
|
|
audience, err := d.String(tce.MaxAudienceLen)
|
|
if err != nil {
|
|
return nil, fieldErr("audience", err)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &AuthAssertion{
|
|
PubKey: pub, Challenge: bytes.Clone(challenge), Scope: scope,
|
|
Audience: audience, CreatedAt: createdAt,
|
|
}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|
|
|
|
// DecodeDelegationClaim parses a DelegationClaim from its canonical bytes.
|
|
func DecodeDelegationClaim(b []byte) (*DelegationClaim, error) {
|
|
if len(b) > tce.MaxDelegTCE {
|
|
return nil, tce.ErrObjectTooLarge
|
|
}
|
|
d := tce.NewDecoder(b)
|
|
tag, err := d.Header()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tag != tce.TagDelegation {
|
|
return nil, ErrWrongObject
|
|
}
|
|
granter, err := decodeIdentityField(d, "granter")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grantee, err := decodeIdentityField(d, "grantee")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
predRaw, err := d.Map(1)
|
|
if err != nil {
|
|
return nil, fieldErr("predicates", err)
|
|
}
|
|
predicates := make(map[string]tce.Value, len(predRaw))
|
|
for k, v := range predRaw {
|
|
bv, ok := v.Bool()
|
|
if !ok || !bv {
|
|
return nil, fieldErr("predicates:"+k, tce.ErrValueTag)
|
|
}
|
|
predicates[k] = v
|
|
}
|
|
maxDepth, err := d.Uvarint()
|
|
if err != nil {
|
|
return nil, fieldErr("max_depth", err)
|
|
}
|
|
createdAt, err := d.Timestamp(false)
|
|
if err != nil {
|
|
return nil, fieldErr("created_at", err)
|
|
}
|
|
expiresAt, err := d.Timestamp(true)
|
|
if err != nil {
|
|
return nil, fieldErr("expires_at", err)
|
|
}
|
|
if expiresAt != 0 && expiresAt <= createdAt {
|
|
return nil, fieldErr("expires_at", tce.ErrExpiry)
|
|
}
|
|
serial, err := d.Uvarint()
|
|
if err != nil {
|
|
return nil, fieldErr("serial", err)
|
|
}
|
|
nonce, err := d.FixedBytes(tce.NonceSize)
|
|
if err != nil {
|
|
return nil, fieldErr("nonce", err)
|
|
}
|
|
if err := checkEnd(d); err != nil {
|
|
return nil, err
|
|
}
|
|
o := &DelegationClaim{
|
|
Granter: granter,
|
|
Grantee: grantee,
|
|
Predicates: predicates,
|
|
MaxDepth: maxDepth,
|
|
CreatedAt: createdAt,
|
|
ExpiresAt: expiresAt,
|
|
Serial: serial,
|
|
Nonce: nonce,
|
|
}
|
|
o.tce = bytes.Clone(b)
|
|
return o, nil
|
|
}
|