- 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
335 lines
11 KiB
Go
335 lines
11 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/internal/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) }
|