- server: relay storing signed objects (PUT/GET), per-IP rate limiting, per-subject quota (1000), one-response-per-request, pagination, /v1/healthz /v1/readyz /v1/metrics - verify: signature-verifying trust evaluator; every object is checked via env.Verify(), approvals via VerifyApprovalResponse, revocations via VerifyRevocationOf; k-of-n approval quorum - docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and the endpoint/status-code contract - tests: server, verify, and ratelimit packages
209 lines
6.9 KiB
Go
209 lines
6.9 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)
|
|
}
|