Documentation
¶
Overview ¶
Package apikey mints and verifies machine-to-machine credentials.
A key is two halves: a public identifier that is stored in clear and used to find the row, and a secret that is stored only as a sha256. The secret is shown once, at creation, and never again.
sha256 rather than argon2id, and the difference matters. A password is short, human-chosen, and worth grinding for, so it gets a deliberately slow function. A key secret is 256 bits from the system's random source: no amount of guessing will find it, and running a memory-hard function on every machine request would be a denial of service aimed at ourselves.
Index ¶
- Constants
- Variables
- type Config
- type FailureCache
- type FailureCacheConfig
- type Key
- type KeyCache
- type KeyCacheConfig
- type Kind
- type Manager
- func (m *Manager) List(ctx context.Context, tenantID uuid.UUID) ([]*Key, error)
- func (m *Manager) Mint(ctx context.Context, in MintInput) (Minted, error)
- func (m *Manager) Revoke(ctx context.Context, tenantID, id uuid.UUID) error
- func (m *Manager) Rotate(ctx context.Context, tenantID, id uuid.UUID, overlap time.Duration) (Minted, error)
- func (m *Manager) Verify(ctx context.Context, presented string, from netip.Addr) (tenancy.Claims, *Key, error)
- type MemoryStore
- func (s *MemoryStore) ByKeyID(_ context.Context, keyID string) (*Key, error)
- func (s *MemoryStore) Find(_ context.Context, tenantID, id uuid.UUID) (*Key, error)
- func (s *MemoryStore) InTx(ctx context.Context, fn func(ctx context.Context) error) error
- func (s *MemoryStore) Insert(_ context.Context, k *Key) error
- func (s *MemoryStore) List(_ context.Context, tenantID uuid.UUID) ([]*Key, error)
- func (s *MemoryStore) Revoke(_ context.Context, tenantID, id uuid.UUID, at time.Time) error
- func (s *MemoryStore) SetExpiry(_ context.Context, tenantID, id uuid.UUID, at time.Time) error
- func (s *MemoryStore) TouchLastUsed(_ context.Context, id uuid.UUID, at time.Time) error
- type MintInput
- type Minted
- type Store
Constants ¶
const DefaultTouchInterval = 5 * time.Minute
DefaultTouchInterval is how often a key's last use is recorded.
const FailureTopic = "apikeyfail"
FailureTopic is the name invalidations for the failure count travel under.
A second topic rather than a second use of KeyTopic, though both are keyed by the same public identifier. They are withdrawn by different writes — a rotation changes the key, a wrong secret changes the count — and one topic would mean every failed attempt against a key also threw away the verified row for it, which is a grinder deciding how often the honest caller pays for a lookup.
const KeyTopic = "apikey"
KeyTopic is the name this package's invalidations travel under.
Exported for the reason [session.TokenTopic] is: it is half of an agreement, and a channel somebody is reading with psql should be readable.
const Prefix = "rig_sk_"
Prefix makes a leaked key identifiable on sight — by a scanner watching a public repository, and by whoever has to decide what it is.
Variables ¶
var ErrInvalidKey = rigerr.Unauthorized("the API key is not valid")
ErrInvalidKey covers everything a caller could present that does not resolve to a usable key: malformed, unknown, wrong secret, revoked, expired, or coming from an address the key is not allowed to use.
One error for all of them, because there is nothing a legitimate client does differently in any of those cases, and telling an attacker which half of the guess was right is a free hint.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
Store Store
Log authlog.Log
// TouchInterval bounds how often a key's last-used time is written.
//
// Recording it on every request would turn a read into a write on the
// hottest path a machine integration has, for a field nobody reads more
// precisely than "today". Five minutes is close enough to answer "is this
// key still in use" and cheap enough to be free.
TouchInterval time.Duration
// DefaultTTL bounds a key with no explicit expiry. Zero means keys can be
// minted that never expire, which is sometimes what a deployment wants and
// should always be a decision rather than a default.
DefaultTTL time.Duration
// Limiter and FailureLimit bound how many times one key identifier may be
// presented wrongly.
//
// Both or neither; nil is a manager nobody wired a limiter into. What this
// stops is somebody grinding secrets against a key id they have learned —
// the id is the public half and turns up in logs and configuration, and
// without a limit the only thing between it and the secret is how fast they
// can ask.
//
// A success clears the window, so an integration that was misconfigured for
// a minute is not locked out for the rest of the hour once it is fixed.
Limiter *throttle.Limiter
FailureLimit throttle.Limit
// Cache keeps verified keys in memory, and is told to stop.
//
// Nil, the default, reads the row on every request. A cache built by
// [NewKeyCache] means most requests do not, and that a revocation or a
// rotation reaches every replica on the transaction that performed it.
Cache *KeyCache
// FailureCache keeps the "no failures for this key" answer in memory.
//
// Nil, the default, counts rows on every request — including every
// successful one, since the limit is checked before the key is looked up. A
// cache built by [NewFailureCache] means the integration that never gets its
// key wrong stops paying for the caller who does.
//
// Separate from [Config.Cache] because they answer different questions and
// are withdrawn by different writes, not because a deployment would sensibly
// want one and not the other: [auth.New] builds both or neither.
FailureCache *FailureCache
Now func() time.Time
}
Config builds a manager.
type FailureCache ¶
type FailureCache struct {
// contains filtered or unexported fields
}
FailureCache holds the one answer about a key's failure count worth holding: that there are none.
Turning the cache on removes the row read from Manager.Verify and leaves this one, which is then the whole cost of an API-key request: [Manager.checkFailureLimit] runs before the lookup on purpose, so a caller grinding secrets stops costing a query long before they stop asking. That ordering is right and stays; what it should not do is charge the same query to the integration that has never once got its key wrong.
**Only a zero is remembered, and that is what makes this sound rather than merely cheap.** Inside a window the count only rises, and every row it counts is one rig writes — so a held "no failures" can be wrong only if a failure was recorded, and a failure recorded is a notification published on the way past. A count that is already above zero is never held: that caller is the one the limit exists for, and their next attempt should be counted rather than guessed at.
Compare throttle.Local, which is the same optimisation without the channel and is deliberately not used for these limits. Its error is one-sided in the permissive direction — a replica sees only its own tally — so it allows an interval of traffic per replica past the limit. This has no such window, because the writes that move the answer are the writes that withdraw it, and a replica that has lost the channel stops answering from memory altogether.
The key id is the half a caller supplies and the limit is checked before the lookup, so an entry is stored under whatever was presented — including an identifier nobody minted. Those are withdrawn in this process only and never published; see [Manager.recordUnknownFailure] for why that loses nothing and what it stops an unauthenticated caller from driving.
A nil *FailureCache is usable and caches nothing.
func NewFailureCache ¶
func NewFailureCache(bus *cache.Bus, cfg FailureCacheConfig) *FailureCache
NewFailureCache registers a cache on a bus and returns it.
type FailureCacheConfig ¶
type FailureCacheConfig struct {
// TTL is how long "no failures" may be reused with no invalidation
// arriving. Zero or less caches nothing.
TTL time.Duration
// MaxEntries bounds the map. Zero takes the package default.
MaxEntries int
// Now is the clock, for tests.
Now func() time.Time
}
FailureCacheConfig is what a FailureCache needs beyond its bus.
type Key ¶
type Key struct {
ID uuid.UUID
TenantID uuid.UUID
AccountID uuid.UUID
// Kind is whose key it is: an integration's, with a service account of its
// own, or a person's.
Kind Kind
Name string
// KeyID is the public half. It is what a person sees in a list, what a log
// line names, and what a rate limit counts against — including for keys
// that do not exist, which is the case that matters.
KeyID string
SecretHash []byte
// Scopes are permission keys, the same ones RBAC uses. Sharing the
// vocabulary is what keeps a key from ever being able to express more than
// a role could.
Scopes []string
// CIDRAllowList restricts where the key may be used from. Empty means
// anywhere.
CIDRAllowList []netip.Prefix
CreatedAt time.Time
CreatedByAccountID *uuid.UUID
ExpiresAt *time.Time
LastUsedAt *time.Time
RevokedAt *time.Time
}
Key is one row.
type KeyCache ¶
type KeyCache struct {
// contains filtered or unexported fields
}
KeyCache holds verified keys, and is how a revocation reaches the replicas holding them.
A machine integration is the traffic this helps most and the traffic a cache is most often wrong about: one key, called as fast as a loop can call it, for a row that changes when somebody rotates it. Which is why the entry is withdrawn on the transaction that rotated it rather than waiting for a lifetime to run out.
A nil *KeyCache is usable and caches nothing.
func NewKeyCache ¶
func NewKeyCache(bus *cache.Bus, cfg KeyCacheConfig) *KeyCache
NewKeyCache registers a cache on a bus and returns it.
type KeyCacheConfig ¶
type KeyCacheConfig struct {
// TTL is how long a verified key may be reused with no invalidation
// arriving. Zero or less caches nothing.
TTL time.Duration
// MaxEntries bounds the map. Zero takes the package default.
MaxEntries int
// Now is the clock, for tests.
Now func() time.Time
}
KeyCacheConfig is what a KeyCache needs beyond its bus.
type Kind ¶
type Kind string
Kind is whose key it is.
const ( // KindIntegration acts as a service account. Revoking the person who set it // up does not stop it, which is the point: an integration outlives the // employee who connected it. KindIntegration Kind = "Integration" // KindPersonal acts as its owner, for somebody automating their own work. // What it can reach is exactly what they can reach, so it needs no separate // permission story — and when they leave, it leaves with them. KindPersonal Kind = "Personal" )
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager mints and verifies keys.
func (*Manager) List ¶
List returns a tenant's keys. The secrets are not in them; there is nothing stored that could produce one.
func (*Manager) Revoke ¶
Revoke kills a key immediately.
It reads the key before killing it, which is one query on a path nobody calls twice a second. The public identifier is what a request presents and therefore what anything holding this key is holding it under, and this method is given the row's uuid — so without the read there is nothing to name in the invalidation.
func (*Manager) Rotate ¶
func (m *Manager) Rotate(ctx context.Context, tenantID, id uuid.UUID, overlap time.Duration) (Minted, error)
Rotate mints a replacement and gives the old key an overlap to die in.
Revoking immediately would break whatever is deployed with the old value the moment the new one is created, which is why nobody rotates keys. An overlap makes it a two-step the operator can actually complete.
func (*Manager) Verify ¶
func (m *Manager) Verify(ctx context.Context, presented string, from netip.Addr) (tenancy.Claims, *Key, error)
Verify resolves a presented key into claims.
The address is checked against the key's allow list, so pass the one the request actually came from — not one read out of a header a client controls, unless a proxy you trust set it.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is an in-process Store, for tests.
func (*MemoryStore) Insert ¶
func (s *MemoryStore) Insert(_ context.Context, k *Key) error
Insert implements Store.
type MintInput ¶
type MintInput struct {
TenantID uuid.UUID
// AccountID is the account the key acts as. Every write the key makes is
// stamped with it, so a machine's changes are attributable to something
// rather than to nobody.
//
// For an integration this is a service account of its own, which is what
// makes "the integration did it" a thing the audit trail can say. For a
// personal key it is the person, and must equal CreatedByAccountID.
AccountID uuid.UUID
// Kind is whose key this is. The zero value is an integration key, because
// that is the one with the sharper failure mode if it is wrong: a personal
// key mistaken for an integration acts as a person who can be deactivated,
// which fails safely, while the reverse would silently widen what the key
// can reach.
Kind Kind
Name string
Scopes []string
CIDRAllowList []netip.Prefix
ExpiresAt *time.Time
// CreatedByAccountID is the person who minted it.
CreatedByAccountID *uuid.UUID
}
MintInput describes a new key.
type Minted ¶
type Minted struct {
Key *Key
// Secret is the whole value to hand over. It is not recoverable: showing
// it again would mean storing something worth stealing.
Secret string
}
Minted is a new key and its secret, at the only moment the secret exists.
type Store ¶
type Store interface {
Insert(ctx context.Context, k *Key) error
// ByKeyID reads a key by its public half, or nil when there is none.
ByKeyID(ctx context.Context, keyID string) (*Key, error)
// Find reads a key by identifier within a tenant.
Find(ctx context.Context, tenantID, id uuid.UUID) (*Key, error)
// TouchLastUsed records that a key was used.
TouchLastUsed(ctx context.Context, id uuid.UUID, at time.Time) error
// Revoke kills a key.
Revoke(ctx context.Context, tenantID, id uuid.UUID, at time.Time) error
// SetExpiry ends a key at a chosen time, for a rotation's overlap window.
SetExpiry(ctx context.Context, tenantID, id uuid.UUID, at time.Time) error
// List returns a tenant's keys, newest first.
List(ctx context.Context, tenantID uuid.UUID) ([]*Key, error)
// InTx runs fn inside one transaction, joining one already in progress.
//
// Here for the reason [session.Store] has it: a revocation and the
// notification that withdraws the cached copy of what was revoked have to
// commit together, or a replica can be told to forget something that was
// never revoked — or, worse, not told about one that was.
InTx(ctx context.Context, fn func(ctx context.Context) error) error
}
Store is the persistence a manager needs.