Documentation
¶
Overview ¶
Package capability is the orchestrator half of the capability-token scheme (GOBLIN-DIV-015, DDR-9): Ed25519 bearer-token issuance with a clamped TTL, and a revocation Bloom filter that rides gossip. The kernel half - the single verification codepath - lives in gapi core/crypto.
Index ¶
- Constants
- Variables
- func LoadOperatorKey(path string) (*goblinv1.OperatorKey, error)
- func NewOperatorKey(pub ed25519.PublicKey, comment string) (*goblinv1.OperatorKey, error)
- func OperatorKeyID(pub ed25519.PublicKey) string
- func RightForVerb(v Verb) (uint64, error)
- func SignOperatorKeyChange(payload *goblinv1.OperatorKeyChangePayload, priv ed25519.PrivateKey) (*goblinv1.OperatorKeyChange, error)
- func ValidateOperatorKey(k *goblinv1.OperatorKey) error
- func VerifyOperatorKeyChange(chg *goblinv1.OperatorKeyChange, resolve OperatorKeyResolver) (*goblinv1.OperatorKeyChangePayload, error)
- type Generation
- type Issuer
- type OperatorKeyResolver
- type Revocations
- type Stats
- type Verb
Constants ¶
const ( TTLDefault = 120 * time.Second TTLMin = 60 * time.Second TTLMax = 300 * time.Second )
TTL policy (operator decision 2026-07-28): 120s default, clamped to the 60-300s range from the proto-2 plan.
const ( RightAgentRegister uint64 = 1 << 8 RightAgentScale uint64 = 1 << 9 RightAgentDelete uint64 = 1 << 10 RightNodeDrain uint64 = 1 << 11 RightJobSubmit uint64 = 1 << 12 RightJobMigrate uint64 = 1 << 13 RightEventPublish uint64 = 1 << 14 )
Orchestration rights occupy bits 8 and up. Bits 0-7 belong to the kernel (gapi core/crypto RightSignal*), which enforces them at the pidfd delivery boundary; the orchestrator never redefines them. The two namespaces are disjoint by construction, so a token minted for one class can never satisfy a requirement from the other.
const DefaultRotationPeriod = TTLMax
DefaultRotationPeriod is how long a generation accumulates before it is retired.
It MUST NOT be shorter than the longest token TTL. Rotating faster would forget a revocation while the token it revokes is still valid, which is a security hole rather than a tuning choice. TTLMax is that bound, so the period is defined from it rather than written as a number somebody has to keep in sync.
const DefaultSyncInterval = TTLMax / 10
DefaultSyncInterval is how often a node exchanges filters with a peer to repair revocations the delta broadcast dropped (GOBLIN-DIV-057).
Anti-entropy converges eventually rather than instantly, so this interval IS the exposure window for a node that missed a delta - it replaces the TTLMax-long window that node would otherwise carry. It must therefore stay well inside a token lifetime, which is why it is derived from TTLMax rather than written as a number somebody has to keep in sync: a tenth of the lifetime gives ten repair opportunities before the token in question expires, so no single lost round trip decides the outcome.
A constant, not configuration. Every non-null default in this repo has cost a defect recently; make it configurable when someone needs it to be, not in advance.
Variables ¶
var ( // ErrOperatorKeyMalformed covers a wrong-sized key, a key id that // does not match its bytes, and an unparseable key file. ErrOperatorKeyMalformed = errors.New("operator key: malformed") // ErrOperatorKeyUnknown means the authorizing key id resolved to // nothing. Fail closed: an unresolvable signer is not a signer. ErrOperatorKeyUnknown = errors.New("operator key: unknown key id") // ErrOperatorKeySignature means the Ed25519 check failed. ErrOperatorKeySignature = errors.New("operator key change: signature verification failed") )
Operator identity (GOBLIN-DIV-015 piece 1). An operator is an Ed25519 keypair, independent of TLS: the default deployment does not require mTLS, so the transport cannot be the thing that says who is calling.
Nothing here touches Raft or the clock. The key-id, signing, and verification helpers are pure functions of their arguments, because the FSM calls them inside Apply and Apply must decide identically on every replica.
LoadOperatorKey is the exception and must NEVER be called from Apply: it reads local disk, and replicas do not have the same files. It runs at node startup, to build a seed the node then proposes through Raft.
Functions ¶
func LoadOperatorKey ¶
func LoadOperatorKey(path string) (*goblinv1.OperatorKey, error)
LoadOperatorKey reads a hex-encoded Ed25519 public key from disk - the same on-disk shape gapi's LoadPublic writes, so an operator key and an agent verify key are produced by the same tooling.
func NewOperatorKey ¶
NewOperatorKey builds a registry record with its id derived.
func OperatorKeyID ¶
OperatorKeyID derives a key's registry id: lowercase hex SHA-256 over the raw 32 public key bytes.
Deliberately not gapicrypto.PEM.FingerprintPublicKey, which hashes the PKIX DER encoding. The registry stores raw key bytes, so the id hashes raw key bytes; going through DER would make the id depend on an encoding the registry never sees.
func RightForVerb ¶
RightForVerb maps a mutating verb to the single right that authorizes it, mirroring the kernel's RightForSignal. Unknown verbs fail closed with the kernel's rights error, so callers branch on one typed error for both halves of the scheme.
func SignOperatorKeyChange ¶
func SignOperatorKeyChange(payload *goblinv1.OperatorKeyChangePayload, priv ed25519.PrivateKey) (*goblinv1.OperatorKeyChange, error)
SignOperatorKeyChange serializes payload and signs the literal bytes, mirroring SignCapabilityToken: protobuf serialization is not canonical, so verifiers must never re-serialize before checking.
func ValidateOperatorKey ¶
func ValidateOperatorKey(k *goblinv1.OperatorKey) error
ValidateOperatorKey checks a record's internal consistency. The FSM calls it on every record it is asked to store, so a record whose id lies about its bytes never reaches replicated state.
func VerifyOperatorKeyChange ¶
func VerifyOperatorKeyChange(chg *goblinv1.OperatorKeyChange, resolve OperatorKeyResolver) (*goblinv1.OperatorKeyChangePayload, error)
VerifyOperatorKeyChange checks structure and signature and returns the verified payload. Like VerifyCapabilityToken it parses before verifying only to learn the key id, and trusts no other field until the signature has passed.
It checks no expiry, because it runs inside FSM Apply where the clock is not a shared input. Replay is the FSM's job, via prev_serial.
Types ¶
type Generation ¶
Generation is one live generation on the wire: its absolute index and its filter bytes.
The index is what makes periodic exchange safe. Without it a receiver can only merge into whatever is current locally, which renews every ingested entry; two nodes whose rotation windows are offset then keep handing an entry back and forth into ever-later windows and it never ages out (GOBLIN-DIV-057).
type Issuer ¶
type Issuer struct {
// contains filtered or unexported fields
}
Issuer mints capability tokens under a per-boot Ed25519 keypair. The public key travels in the node's serf tags (gossip), so any node can resolve key_id -> key without a Raft round-trip.
func NewIssuer ¶
NewIssuer generates a fresh keypair; the key id is a UUIDv7, so key generations sort by boot time.
func (*Issuer) Issue ¶
func (i *Issuer) Issue(subjectUUID []byte, rights uint64, ttl time.Duration) (*gapiv1.CapabilityToken, []byte, error)
Issue mints a bearer token for one subject with the given rights. The subject is usually an agent instance, but orchestration tokens scope to a named resource instead - spec, node, job, or topic (GOBLIN-DIV-027) - which is why it is not called an instance UUID. ttl 0 means the 120s default; anything outside 60-300s is clamped. Returns the token and its raw UUIDv7 token id (the revocation handle).
type OperatorKeyResolver ¶
OperatorKeyResolver maps an authorizing key id to its public key. Returning false fails the change closed with ErrOperatorKeyUnknown.
type Revocations ¶
type Revocations struct {
// contains filtered or unexported fields
}
Revocations is the revocation set: a generational Bloom filter.
The set-only-grows design it replaced could not forget, and nothing ever called Revoke in production, so the defect was dormant - an empty filter has a 0% false-positive rate forever. Wiring a producer is exactly what would have started filling it, and at 2000 entries it refuses 15% of VALID tokens; at 4000, 54%. The missing producer was masking the missing rotation (GOBLIN-DIV-015).
func NewRevocations ¶
func NewRevocations() *Revocations
NewRevocations creates an empty filter rotating at the default period.
func NewRevocationsWithPeriod ¶
func NewRevocationsWithPeriod(period time.Duration) *Revocations
NewRevocationsWithPeriod creates an empty filter with an explicit rotation period. A period below TTLMax is raised to it: a caller asking to forget revocations faster than tokens expire is asking for a hole, so the floor is enforced rather than documented.
func (*Revocations) Ingest ¶
func (r *Revocations) Ingest(gens []Generation) error
Ingest merges a peer's generations into the local generations with the SAME absolute index.
An entry therefore keeps the lifetime it was given when it was revoked, no matter how many times it is exchanged: a generation the receiver has already retired is dropped rather than renewed, and one from the future is ignored because the receiver will compute that index itself when the clock reaches it.
func (*Revocations) IsRevoked ¶
func (r *Revocations) IsRevoked(tokenID []byte) bool
IsRevoked reports whether a token id is (probabilistically) revoked.
Every live generation is consulted, so an entry remains visible for at least one full period after it was recorded - never less than the longest token TTL.
func (*Revocations) Revoke ¶
func (r *Revocations) Revoke(tokenID []byte)
Revoke marks a token id revoked.
func (*Revocations) Snapshot ¶
func (r *Revocations) Snapshot() []Generation
Snapshot returns EVERY live generation, each tagged with its absolute index.
It exists for anti-entropy - a periodic full-state exchange that repairs revocations a best-effort delta broadcast dropped.
All live generations go on the wire, not just the current one. The filter keeps two because an entry must outlive the longest token TTL wherever in the period it landed; exporting only the current one would repair a strictly narrower window than the filter itself maintains, and a revocation made late in a window would become invisible to peers the moment that window rolled - while the token it revoked was still valid. Sending them separately rather than flattened is what keeps this safe: each generation carries the index that fixes its lifetime.
func (*Revocations) Stats ¶
func (r *Revocations) Stats() Stats
Stats returns a snapshot of the filter's load.
type Stats ¶
type Stats struct {
CurrentGeneration int // entries in the current generation
Total uint64 // entries since process start
Uptime time.Duration // how long this filter has existed
RatePerSecond float64 // observed, not assumed
Capacity int // entries per generation at ~1% false positives
Period time.Duration
}
Stats reports what the filter is actually carrying.
The geometry above encodes an ASSUMED revocation rate. This is how the assumption gets checked against a running cluster instead of trusted: RatePerSecond over a long uptime is the number the size should have been chosen from.