niko_trust/internal/protocol/decode.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

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
}