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

254 lines
8.4 KiB
Go

package protocol
import (
"fmt"
"git.n1ko.dev/Niko/niko_trust/internal/address"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Encoders for the six protocol objects.
//
// Each encoder writes the fields in exactly the order given in PROTOCOL.md
// section 8 and enforces that section's limits on top of the primitive
// constraints that internal/tce already applies. There are no optional fields:
// every field is always present, an empty string encodes as a single 0x00 and
// an empty map as a single 0x00, so the encoding cannot drift between call
// sites.
// fieldErr annotates a sentinel error with the field that failed, without
// including any input data.
func fieldErr(field string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", field, err)
}
// finishEncode returns the encoder's bytes after applying the per-object whole
// limit from PROTOCOL.md section 6.3.
func finishEncode(e *tce.Encoder, limit int) ([]byte, error) {
b, err := e.Bytes()
if err != nil {
return nil, err
}
if len(b) > limit {
return nil, tce.ErrObjectTooLarge
}
return b, nil
}
// EncodeIdentity encodes an IdentityRegistration, object tag 0x01.
//
// Field order: identity, alias, created_at. The alias appears in this object
// and in no other (INV-7). The key is validated as a curve point before it is
// written, so a degenerate key can never be signed.
func EncodeIdentity(o *Identity) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.PubKey); err != nil {
return nil, fieldErr("identity", err)
}
e := tce.NewEncoder()
e.Header(tce.TagIdentity)
e.Identity("identity", o.PubKey)
e.String("alias", o.Alias, tce.MaxAliasLen)
e.Timestamp("created_at", o.CreatedAt, false)
return finishEncode(e, tce.MaxIdentityTCE)
}
// EncodeClaim encodes a Claim, object tag 0x02.
//
// Field order: issuer, subject, claims, created_at, expires_at, serial,
// nonce. expires_at of 0 means "does not expire"; otherwise it must be
// strictly after created_at.
func EncodeClaim(o *Claim) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Issuer); err != nil {
return nil, fieldErr("issuer", err)
}
if err := address.ValidatePubKey(o.Subject); err != nil {
return nil, fieldErr("subject", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
if o.ExpiresAt != 0 && o.ExpiresAt <= o.CreatedAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
e := tce.NewEncoder()
e.Header(tce.TagClaim)
e.Identity("issuer", o.Issuer)
e.Identity("subject", o.Subject)
e.Map("claims", o.Claims, 1)
e.Timestamp("created_at", o.CreatedAt, false)
e.Timestamp("expires_at", o.ExpiresAt, true)
e.Uvarint(o.Serial)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxClaimTCE)
}
// EncodeRevocation encodes a Revocation, object tag 0x03.
//
// Field order: issuer, claim_id, reason, created_at, nonce. The binding
// between a revocation and the claim it withdraws is enforced by
// VerifyRevocationOf once both objects are verified.
func EncodeRevocation(o *Revocation) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Issuer); err != nil {
return nil, fieldErr("issuer", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagRevocation)
e.Identity("issuer", o.Issuer)
e.FixedBytes("claim_id", o.ClaimID[:], tce.HashSize)
e.String("reason", o.Reason, tce.MaxReasonLen)
e.Timestamp("created_at", o.CreatedAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxRevocTCE)
}
// EncodeApprovalRequest encodes an ApprovalRequest, object tag 0x04.
//
// Field order: sender, recipient, action, payload, message, created_at,
// expires_at, nonce. expires_at must be after created_at 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.
func EncodeApprovalRequest(o *ApprovalRequest) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Sender); err != nil {
return nil, fieldErr("sender", err)
}
if err := address.ValidatePubKey(o.Recipient); err != nil {
return nil, fieldErr("recipient", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
if o.ExpiresAt <= o.CreatedAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
if o.ExpiresAt-o.CreatedAt > tce.MaxApprovalLifetime {
return nil, fieldErr("expires_at", tce.ErrLifetime)
}
e := tce.NewEncoder()
e.Header(tce.TagApprovalRequest)
e.Identity("sender", o.Sender)
e.Identity("recipient", o.Recipient)
e.String("action", o.Action, tce.MaxActionLen)
e.Map("payload", o.Payload, 0)
e.String("message", o.Message, tce.MaxMessageLen)
e.Timestamp("created_at", o.CreatedAt, false)
e.Timestamp("expires_at", o.ExpiresAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxRequestTCE)
}
// EncodeApprovalResponse encodes an ApprovalResponse, object tag 0x05.
//
// Field order: request_hash, responder, decision, created_at, nonce.
// request_hash comes first because it is the field that gives the object its
// meaning. The decision may be only deny (0) or allow (1); a verifier cannot
// be left with an outcome it has no rule for.
func EncodeApprovalResponse(o *ApprovalResponse) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if !o.Decision.Valid() {
return nil, fieldErr("decision", tce.ErrDecision)
}
if err := address.ValidatePubKey(o.Responder); err != nil {
return nil, fieldErr("responder", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagApprovalResponse)
e.FixedBytes("request_hash", o.RequestHash[:], tce.HashSize)
e.Identity("responder", o.Responder)
e.Uvarint(uint64(o.Decision))
e.Timestamp("created_at", o.CreatedAt, false)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxResponseTCE)
}
// EncodeAuthAssertion encodes an AuthAssertion, object tag 0x06.
//
// Field order: identity, challenge, scope, audience, created_at. The audience
// is signed so an assertion produced for one server cannot be replayed to
// another; its binding is checked by VerifyAuthAssertion.
func EncodeAuthAssertion(o *AuthAssertion) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.PubKey); err != nil {
return nil, fieldErr("identity", err)
}
if len(o.Challenge) != tce.ChallengeSize {
return nil, fieldErr("challenge", tce.ErrFieldSize)
}
e := tce.NewEncoder()
e.Header(tce.TagAuthAssertion)
e.Identity("identity", o.PubKey)
e.FixedBytes("challenge", o.Challenge, tce.ChallengeSize)
e.String("scope", o.Scope, tce.MaxScopeLen)
e.String("audience", o.Audience, tce.MaxAudienceLen)
e.Timestamp("created_at", o.CreatedAt, false)
return finishEncode(e, tce.MaxAuthTCE)
}
// EncodeDelegationClaim encodes a DelegationClaim, object tag 0x07.
//
// Field order: granter, grantee, predicates, max_depth, created_at,
// expires_at, serial, nonce. Every predicate value must be the boolean true:
// a delegation covers a key or it does not, and any other spelling would
// make two implementations disagree about coverage while both saw a valid
// signature.
func EncodeDelegationClaim(o *DelegationClaim) ([]byte, error) {
if o == nil {
return nil, ErrNil
}
if err := address.ValidatePubKey(o.Granter); err != nil {
return nil, fieldErr("granter", err)
}
if err := address.ValidatePubKey(o.Grantee); err != nil {
return nil, fieldErr("grantee", err)
}
if len(o.Nonce) != tce.NonceSize {
return nil, fieldErr("nonce", tce.ErrFieldSize)
}
if o.ExpiresAt != 0 && o.ExpiresAt <= o.CreatedAt {
return nil, fieldErr("expires_at", tce.ErrExpiry)
}
if len(o.Predicates) < 1 || len(o.Predicates) > tce.MaxMapEntries {
return nil, fieldErr("predicates", tce.ErrEmptyMap)
}
for k, v := range o.Predicates {
b, ok := v.Bool()
if !ok || !b {
return nil, fieldErr("predicates:"+k, tce.ErrValueTag)
}
}
e := tce.NewEncoder()
e.Header(tce.TagDelegation)
e.Identity("granter", o.Granter)
e.Identity("grantee", o.Grantee)
e.Map("predicates", o.Predicates, 1)
e.Uvarint(o.MaxDepth)
e.Timestamp("created_at", o.CreatedAt, false)
e.Timestamp("expires_at", o.ExpiresAt, true)
e.Uvarint(o.Serial)
e.FixedBytes("nonce", o.Nonce, tce.NonceSize)
return finishEncode(e, tce.MaxDelegTCE)
}