niko_trust/pkg/protocol/encode.go
Niko Marmeladkov 3bf13fa488 Public SDK packages, proxy-aware rate limits, service login recipe
- internal/{address,identity,protocol,tce,transport,verify} -> pkg/ so
  external Go projects can import the verified core; invariant tests
  updated for the new paths
- Config.TrustProxy: key rate limiting by X-Forwarded-For when the relay
  sits behind a reverse proxy (off by default, header never trusted
  otherwise)
- examples/service + examples/approve: complete passwordless login round
  trip (mint request -> wallet approves -> local verify), run live in CI
- docs/SERVICE-GUIDE.md: the integration recipe
2026-08-26 12:49:54 +03:00

254 lines
8.4 KiB
Go

package protocol
import (
"fmt"
"git.n1ko.dev/Niko/niko_trust/pkg/address"
"git.n1ko.dev/Niko/niko_trust/pkg/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)
}