niko_trust/pkg/protocol/objects.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

396 lines
13 KiB
Go

// Package protocol defines the signed objects of the trust protocol and the
// rules for encoding, decoding and verifying them.
//
// Every object here is a statement made by an identity. The protocol
// establishes who made a statement and that it has not been altered. It never
// decides whether the statement should be believed or what it means: that is
// the consuming application's job (INV-5).
//
// The package can verify signatures but cannot create them. Signing lives in
// internal/identity/signer, which server-side code does not import, so a
// compromised server has no ability to forge anything (INV-1).
//
// This package does not import encoding/json. There is exactly one signing
// representation, and JSON is a transport syntax handled elsewhere
// (docs/IMPLEMENTATION_NOTES.md property 8).
package protocol
import (
"bytes"
"errors"
"git.n1ko.dev/Niko/niko_trust/pkg/tce"
)
// Errors returned when an object violates a protocol rule.
var (
// ErrNil is returned when an encoder or verifier is handed a nil object.
ErrNil = errors.New("protocol: nil object")
// ErrSignature means the Ed25519 signature did not verify over the
// received canonical bytes.
ErrSignature = errors.New("protocol: signature does not verify")
// ErrSignatureSize means the signature was not 64 bytes.
ErrSignatureSize = errors.New("protocol: signature must be 64 bytes")
// ErrWrongObject means the canonical bytes decoded to a different object
// type than the caller expected.
ErrWrongObject = errors.New("protocol: unexpected object type")
// ErrRequestMismatch means an approval response does not commit to the
// request it was presented with.
ErrRequestMismatch = errors.New("protocol: response does not match request")
// ErrWrongResponder means the response was signed by an identity other
// than the request's recipient.
ErrWrongResponder = errors.New("protocol: responder is not the request recipient")
// ErrResponseTiming means the response is dated outside the request's
// validity window.
ErrResponseTiming = errors.New("protocol: response timestamp outside request window")
// ErrWrongIssuer means a revocation was signed by someone other than the
// issuer of the claim it targets.
ErrWrongIssuer = errors.New("protocol: revocation issuer is not the claim issuer")
// ErrWrongClaim means a revocation targets a different claim.
ErrWrongClaim = errors.New("protocol: revocation does not target this claim")
// ErrAudience means an auth assertion was produced for a different
// server.
ErrAudience = errors.New("protocol: auth assertion audience mismatch")
// ErrEmptyAudience means the caller did not supply an expected audience.
ErrEmptyAudience = errors.New("protocol: expected audience must not be empty")
// ErrExpired means the object's expiry has passed.
ErrExpired = errors.New("protocol: object has expired")
// ErrNotYetValid means the object is dated too far in the future.
ErrNotYetValid = errors.New("protocol: object created too far in the future")
// ErrSelfRevocation means a revocation targets an object that is not a
// claim.
ErrSelfRevocation = errors.New("protocol: revocation target is not a claim")
)
// MaxClockSkew is the tolerance applied when comparing a signed timestamp
// with local time.
//
// Timestamps are asserted by the signer, whose clock may differ from the
// verifier's. Without an allowance, honest objects would be rejected; with too
// large an allowance, expiry becomes meaningless. See PROTOCOL.md section
// 13.1.
const MaxClockSkew = 120
// Decision is an approval outcome.
type Decision uint8
// Decision values. There are exactly two; any other encoded value is
// rejected, so a verifier cannot encounter an outcome it has no rule for.
const (
Deny Decision = tce.DecisionDeny
Allow Decision = tce.DecisionAllow
)
// String renders a decision.
func (d Decision) String() string {
switch d {
case Allow:
return "allow"
case Deny:
return "deny"
default:
return "invalid"
}
}
// Valid reports whether d is a defined decision.
func (d Decision) Valid() bool { return d == Allow || d == Deny }
// ClaimStatus is the lifecycle state of a claim as understood by the protocol
// layer.
//
// There is deliberately no "denied" or "not found" status. Absence of a claim
// is not a protocol state at all: a relay can withhold anything, so a consumer
// that treated silence as denial could be manipulated by censorship. What an
// absent claim means is a decision for the application (INV-5, and
// docs/IMPLEMENTATION_NOTES.md property 6).
type ClaimStatus uint8
// Claim statuses.
const (
// StatusActive means the claim verified and has not expired or been
// revoked.
StatusActive ClaimStatus = iota
// StatusExpired means the claim's expires_at has passed.
StatusExpired
// StatusRevoked means a verified revocation by the claim's issuer exists.
StatusRevoked
)
// String renders a status.
func (s ClaimStatus) String() string {
switch s {
case StatusActive:
return "active"
case StatusExpired:
return "expired"
case StatusRevoked:
return "revoked"
default:
return "unknown"
}
}
// Identity is a self-asserted identity registration, object tag 0x01.
//
// Registration is a convenience for discovery, not a prerequisite: an identity
// exists because its key exists.
//
// Instances returned by the decode and verify functions carry the canonical
// bytes they were read from; instances constructed in memory do not.
type Identity struct {
// PubKey is the Ed25519 public key that signed this registration.
PubKey []byte
// Alias is a self-asserted display label. It is signed here so that the
// self-assertion is tamper-evident, but it remains non-authoritative: not
// unique, not verified, and never consulted when verifying any other
// object (INV-7). This is the only object in which an alias appears.
Alias string
// CreatedAt is the signer's assertion of when this was made.
CreatedAt uint64
// tce is the canonical bytes this object was decoded from, and sig the
// signature verified over them. Both are set only by the decode and verify
// functions. The encoders ignore both and rebuild from the field list, so
// an encoding can never drift from this package's rules by accident.
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this object was decoded from, or
// nil for an object constructed in memory. Copies are returned so that a
// caller cannot alter the byte string a verifier relies on.
func (o *Identity) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *Identity) Signature() []byte { return bytes.Clone(o.sig) }
// Claim is a signed statement by an issuer about a subject, object tag 0x02.
//
// The meaning of the keys and values is entirely outside the protocol. To
// every component of the trust system they are opaque strings.
type Claim struct {
Issuer []byte
Subject []byte
// Claims holds the statements. At least one entry, at most 32.
Claims map[string]tce.Value
CreatedAt uint64
// ExpiresAt is 0 for a claim that does not expire, otherwise a timestamp
// strictly after CreatedAt.
ExpiresAt uint64
// Serial lets an issuer supersede an earlier claim about the same
// subject. It is guidance for consumers; no relay enforces an ordering.
Serial uint64
// Nonce makes otherwise identical claims distinct, so that two claims
// with the same content and timestamp have different object IDs.
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this claim was decoded from, or
// nil for a claim constructed in memory.
func (o *Claim) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *Claim) Signature() []byte { return bytes.Clone(o.sig) }
// Revocation withdraws a claim, object tag 0x03.
//
// It must be signed by the same issuer as the claim it targets. A revocation
// signed by anyone else is meaningless and is rejected.
type Revocation struct {
Issuer []byte
ClaimID tce.ID
Reason string
CreatedAt uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this revocation was decoded
// from, or nil for one constructed in memory.
func (o *Revocation) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *Revocation) Signature() []byte { return bytes.Clone(o.sig) }
// ApprovalRequest asks a recipient to approve an opaque action, tag 0x04.
type ApprovalRequest struct {
Sender []byte
Recipient []byte
// Action and Payload are opaque. Neither the relay nor this package
// assigns them meaning.
Action string
Payload map[string]tce.Value
// Message is what a human will read when approving. It is signed, so it
// cannot be altered in transit, but it is written by the sender: a client
// must display the sender's address alongside it and must not present it
// as though the relay endorsed it.
Message string
CreatedAt uint64
// ExpiresAt must be after CreatedAt 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.
ExpiresAt uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this request was decoded from,
// or nil for one constructed in memory.
func (o *ApprovalRequest) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *ApprovalRequest) Signature() []byte { return bytes.Clone(o.sig) }
// ApprovalResponse is a recipient's signed decision, object tag 0x05.
type ApprovalResponse struct {
// RequestHash is the object ID of the exact request being answered. It
// commits to the full canonical request rather than to a sender-chosen
// label, which is what makes a signed decision impossible to move to a
// different request (INV-4).
RequestHash tce.ID
Responder []byte
Decision Decision
CreatedAt uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this response was decoded from,
// or nil for one constructed in memory.
func (o *ApprovalResponse) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *ApprovalResponse) Signature() []byte { return bytes.Clone(o.sig) }
// AuthAssertion proves possession of a private key for one server-issued
// challenge, object tag 0x06.
//
// It is a transport capability only: it authenticates a connection and grants
// nothing.
type AuthAssertion struct {
PubKey []byte
Challenge []byte
Scope string
// Audience binds the assertion to one server, so that an assertion
// produced for one relay cannot be replayed to another. Claims and
// approvals carry no audience because they are global statements meant to
// be portable between relays.
Audience string
CreatedAt uint64
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this assertion was decoded from,
// or nil for one constructed in memory.
func (o *AuthAssertion) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *AuthAssertion) Signature() []byte { return bytes.Clone(o.sig) }
// DelegationClaim grants another identity the right to speak, object tag
// 0x07.
//
// The granter asserts: "grantee may issue claims whose predicate is one of
// Predicates, as if I had issued them myself, re-delegating at most MaxDepth
// further times." Resolution — walking a chain of such objects from a root
// the verifier already trusts down to a claim's actual issuer — lives in
// internal/verify. The protocol layer only establishes who granted what to
// whom and that the grant is intact.
//
// A delegation is revoked exactly like a claim: a Revocation signed by the
// granter targeting this object's ID.
type DelegationClaim struct {
// Granter signs this object and lends its authority.
Granter []byte
// Grantee is the identity allowed to speak under the grant.
Grantee []byte
// Predicates are the exact claim keys covered, each with value true.
// At least one entry, at most 32; prefix or wildcard matching is
// deliberately not a thing (docs/DELEGATION.md).
Predicates map[string]tce.Value
// MaxDepth is how many further delegation hops may sit below this one.
// Zero means the grantee speaks for itself only.
MaxDepth uint64
CreatedAt uint64
// ExpiresAt is 0 for a grant that does not expire, otherwise strictly
// after CreatedAt.
ExpiresAt uint64
// Serial lets the granter supersede an earlier grant with new terms.
Serial uint64
Nonce []byte
tce []byte
sig []byte
}
// TCE returns a copy of the canonical bytes this delegation was decoded
// from, or nil for one constructed in memory.
func (o *DelegationClaim) TCE() []byte { return bytes.Clone(o.tce) }
// Signature returns a copy of the signature verified over the canonical
// bytes, or nil if none was verified.
func (o *DelegationClaim) Signature() []byte { return bytes.Clone(o.sig) }
// Covers reports whether p is among the delegated predicates.
func (o *DelegationClaim) Covers(p string) bool {
v, ok := o.Predicates[p]
if !ok {
return false
}
b, isBool := v.Bool()
return isBool && b
}