- 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.
254 lines
8.4 KiB
Go
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)
|
|
}
|