capability

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 25, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Real Vault dynamic AWS credentials, alongside dbsecrets.go. AWS access is attenuated per call via a real sts:AssumeRole (a cheap, fast, AWS-enforced-TTL operation - unlike a database CREATE ROLE, there's no expensive backend round trip to avoid repeating), so unlike the DB credential (minted once per session), nothing here needs session-level caching to be honest about cost.

One real, important difference from the DB side, found while validating this against a live AWS account, not assumed: an AWS STS temporary credential CANNOT be actively invalidated once issued. Vault's lease revocation for these leases only removes Vault's own bookkeeping - the credential itself keeps working with AWS until its TTL naturally expires, confirmed live (revoked the lease, the same credential still successfully read S3 afterward). This is an AWS platform limitation, not a gap in this implementation - the real security boundary for AWS dynamic secrets is the TTL itself, which is why keeping it short matters more here than it does for the DB path (where a real DROP ROLE gives an actual hard stop).

Biscuit-based root capability + local attenuation - the actual mechanism behind the Fast Capability Plane. Validated against the real biscuit-go/v2 API before being wired in here (mint -> attenuate -> verify round trip, including tamper and forged-key rejection).

The signing key is an ed25519 keypair for the whole proxy's issuing authority - held in a KeyRing (keyring.go), not a single seed baked in forever, so it can be rotated without breaking tokens already in flight. A session's own identity still comes from authority FACTS (delegated_user, delegated_role, a Vault-sourced session seed), never from which signing key happened to be current when it was minted. Attenuation appends a new block signed with its own ephemeral keypair generated locally (that's what Biscuit.Append does internally) - this is the concrete mechanic that makes per-call scoping free of any further network call once the root exists.

Package capability implements the Fast Capability Plane: a root capability minted once per agent session (the expensive, Vault-backed half - see vault.go) from which every individual tool call is then locally attenuated (biscuit.go) into a narrowly-scoped, short-lived token with no further network call. The hot path never pays for a mint: the only network call is the per-session root.

Real Vault dynamic database credentials, validated against a real PostgreSQL instance.

Unlike the biscuit session seed (a cheap Transit call, safe to reissue), a real DB credential costs Vault a genuine `CREATE ROLE` round trip against the database - not something to pay per tool call. So this mints ONE credential per session (same lifecycle as the biscuit root capability), reused for that session's DB-backed calls, with per-call restriction enforced by this proxy's own policy/reversibility layer - not by asking Vault for a new DB principal on every call.

Key rotation for the biscuit signing keypair. Before this, biscuit.go held one seed forever, sourced from a plaintext config.yaml field - fine for a PoC, not something to ship. This file adds the two things that were actually missing: a way to change the signing key without breaking tokens already in flight, and a way for verifiers to learn about a new key without a synchronized manual update.

Deliberately NOT built here: Vault Transit-based signing (having Vault hold the private key and sign on our behalf, so it never exists in the proxy's own memory at all). That's a real, further hardening step, but it needs validating biscuit-go's signer interface against a real Transit sign call first, and isn't worth the added per-session network hop until a customer's security review specifically asks for HSM-backed signing. What's here is the intermediate, still-real step: get the key out of a config file into Vault, and make rotation an explicit, non-automatic action.

Vault integration for the Fast Capability Plane's "paid once per session" half. This is deliberately scoped: it demonstrates the real latency architecture (one genuine network round trip to Vault per session, zero on the per-call hot path) using Vault's Transit engine to source a cryptographically random, audited session seed - it does NOT mint real AWS/DB credentials - dbsecrets.go and awssecrets.go do that, each with its own cost model (a DB credential once per session because CREATE ROLE is expensive; an AWS credential per call because sts:AssumeRole is cheap and AWS-TTL-enforced). The biscuit signing key itself (biscuit.go) is a separate, stable keypair - Vault is not asked to re-mint that per session, since a verifier can only trust one fixed public key.

Index

Constants

This section is empty.

Variables

View Source
var ErrVaultUnavailable = errors.New("capability: vault unavailable")

ErrVaultUnavailable marks an error as caused by Vault being unreachable, timing out, or otherwise failing to answer - as opposed to, say, a malformed response it did answer with. Callers (internal/handler) use errors.Is against this to raise a distinguishable operational signal ("Vault specifically is down") rather than treating every capability mint failure identically.

Functions

func NewVaultClient

func NewVaultClient(addr, token string) (*vaultapi.Client, error)

NewVaultClient builds the one Vault client a proxy process shares between the seed minter (Transit) and the key-ring store (KV v2) - one connection object, not two independently configured ones for what is ultimately the same Vault deployment.

func Verify

func Verify(tokenValue string, publicKeyHexes []string, expectedTool, expectedResource string) error

Verify is what an upstream service (cmd/mockupstream) runs: checks the full signature chain against one of the issuer's currently-servable public keys, then that the specific (tool, resource, now) facts satisfy every check appended during attenuation. A wrong tool, wrong resource, expired token, tampered bytes, or a token forged with any other key all fail here - each was confirmed individually before this code was written. Callers pass every candidate key from GET /biscuit-keys, not a single hardcoded one - that's what lets a signing-key rotation reach verifiers without a synchronized manual update.

Types

type AWSCredential

type AWSCredential struct {
	AccessKeyID     string
	SecretAccessKey string
	SessionToken    string
	LeaseID         string
	LeaseDuration   int // seconds, as Vault reports it
}

AWSCredential is a real, temporary AWS credential minted by Vault via a genuine sts:AssumeRole call - not a token this proxy verifies itself, since AWS's own STS/IAM is the verifier here, the same way the database itself is the verifier for DBCredential.

type AWSCredentialMinter

type AWSCredentialMinter interface {
	MintAWSCredential(ctx context.Context, awsRole string) (AWSCredential, error)
	// RevokeAWSCredential ends the lease in Vault's own bookkeeping. It
	// does NOT invalidate the credential with AWS - see the package
	// comment above. Still worth calling when a caller is done with a
	// credential early, so Vault's lease count/audit trail reflects
	// reality, but callers must not treat this as a security control the
	// way DBCredentialMinter's revoke is.
	RevokeAWSCredential(ctx context.Context, leaseID string) error
}

AWSCredentialMinter sources real, per-call AWS credentials. Interface so tests never need a live Vault+AWS pair - only cmd/rinwall's real wiring uses VaultAWSCredentialMinter.

type BiscuitRootIssuer

type BiscuitRootIssuer struct {
	// contains filtered or unexported fields
}

func NewBiscuitRootIssuer

func NewBiscuitRootIssuer(ctx context.Context, store KeyRingStore, seeder SeedMinter) (*BiscuitRootIssuer, error)

NewBiscuitRootIssuer loads the signing keyring from store, bootstrapping a fresh one on first run (nothing in Vault yet), and prunes any key whose grace period has already fully elapsed before this proxy process ever uses it to mint or serve keys.

func (*BiscuitRootIssuer) CurrentPublicKeyHex

func (i *BiscuitRootIssuer) CurrentPublicKeyHex() (string, error)

CurrentPublicKeyHex is a convenience for a single startup log line - verifiers should fetch the full set from GET /biscuit-keys, not hardcode this one value, since it changes on rotation.

func (*BiscuitRootIssuer) MintRoot

func (i *BiscuitRootIssuer) MintRoot(ctx context.Context, sessionKey, delegatedUser, delegatedRole string) (Root, error)

func (*BiscuitRootIssuer) Rotate

func (i *BiscuitRootIssuer) Rotate(ctx context.Context) (newKeyID string, err error)

Rotate generates a new signing key, makes it current, and persists the change. This is a deliberate operator action (see the admin endpoint in cmd/rinwall), never scheduled or automatic - existing sessions keep verifying against their original key until Prune drops it after its grace period, with zero coordination required from already-running verifiers beyond their normal periodic refresh of GET /biscuit-keys.

func (*BiscuitRootIssuer) ServablePublicKeys

func (i *BiscuitRootIssuer) ServablePublicKeys() ([]PublicKeyEntry, error)

ServablePublicKeys is what GET /biscuit-keys hands out - every signing key a verifier should currently accept.

type DBCredential

type DBCredential struct {
	Username      string
	Password      string
	LeaseID       string
	LeaseDuration int // seconds, as Vault reports it
}

DBCredential is a real, uniquely-generated Postgres role + password, minted by Vault's database secrets engine executing a real CREATE ROLE ... statement against the actual database - not a token this proxy verifies itself (unlike biscuit tokens), since the database itself is the verifier here, the same way a real AWS/DB principal always has to be.

type DBCredentialMinter

type DBCredentialMinter interface {
	MintDBCredential(ctx context.Context, dbRole string) (DBCredential, error)
	RevokeDBCredential(ctx context.Context, leaseID string) error
}

DBCredentialMinter sources real, per-session Postgres credentials. Interface so tests never need a live Vault+Postgres pair - only cmd/rinwall's real wiring uses VaultDBCredentialMinter.

type EphemeralKeyRingStore

type EphemeralKeyRingStore struct {
	// contains filtered or unexported fields
}

EphemeralKeyRingStore holds the biscuit signing keyring in memory only - nothing is ever written to disk, and a restart means a brand-new signing key.

That restart behavior is the deliberate choice, not a limitation worked around: the alternative (a keyring file next to the config) would put private key material back in plaintext on disk, which is precisely what moving the keyring into Vault KV existed to stop. Losing it on restart costs almost nothing here, because verifiers already fetch the current key set from the proxy's own GET /biscuit-keys and refresh periodically rather than hardcoding one, and an attenuated token's TTL is seconds - so there is no meaningful population of in-flight tokens to invalidate.

The real cost is custody: an operator cannot rotate deliberately and keep a grace window across a restart, and there is no record outside this process of which key signed what. That's the trade zero-config makes, and it's why production wants Vault KV.

func (*EphemeralKeyRingStore) Load

func (*EphemeralKeyRingStore) Save

type FakeAWSCredentialMinter

type FakeAWSCredentialMinter struct {
	NextAccessKeyID     string
	NextSecretAccessKey string
	NextSessionToken    string
	MintErr             error
	RevokeErr           error
	Revoked             []string
}

FakeAWSCredentialMinter is an in-memory stand-in for tests - no Vault or AWS dependency, mirrors FakeDBCredentialMinter's role.

func (*FakeAWSCredentialMinter) MintAWSCredential

func (f *FakeAWSCredentialMinter) MintAWSCredential(ctx context.Context, awsRole string) (AWSCredential, error)

func (*FakeAWSCredentialMinter) RevokeAWSCredential

func (f *FakeAWSCredentialMinter) RevokeAWSCredential(ctx context.Context, leaseID string) error

type FakeDBCredentialMinter

type FakeDBCredentialMinter struct {
	NextUsername string
	NextPassword string
	MintErr      error
	RevokeErr    error
	Revoked      []string // leaseIDs passed to RevokeDBCredential, for assertions
}

FakeDBCredentialMinter is an in-memory stand-in for tests - no Vault or database dependency, mirrors FakeSeedMinter's role for the Transit path.

func (*FakeDBCredentialMinter) MintDBCredential

func (f *FakeDBCredentialMinter) MintDBCredential(ctx context.Context, dbRole string) (DBCredential, error)

func (*FakeDBCredentialMinter) RevokeDBCredential

func (f *FakeDBCredentialMinter) RevokeDBCredential(ctx context.Context, leaseID string) error

type FakeKeyRingStore

type FakeKeyRingStore = EphemeralKeyRingStore

FakeKeyRingStore is the name tests have always used for an in-memory keyring. It is an alias for EphemeralKeyRingStore (local.go), which zero-config mode uses for real - see that type for what holding the signing key only in memory actually costs.

type FakeSeedMinter

type FakeSeedMinter = LocalSeedMinter

FakeSeedMinter is the name tests have always used for local seed generation. It is an alias, not a copy: the implementation is LocalSeedMinter in local.go, which zero-config mode uses for real - and there is nothing fake about it, since a session seed is randomness rather than a secret Vault was guarding.

type KeyEntry

type KeyEntry struct {
	ID           string     `json:"id"`
	SeedHex      string     `json:"seed_hex"`
	CreatedAt    time.Time  `json:"created_at"`
	SupersededAt *time.Time `json:"superseded_at,omitempty"`
}

KeyEntry is one signing key's full material plus its rotation lifecycle. SeedHex is the private half - it exists in this struct only because the struct itself is meant to live in Vault, never in a config file or a discovery response.

type KeyRing

type KeyRing struct {
	Keys      []KeyEntry `json:"keys"`
	CurrentID string     `json:"current_id"`
}

KeyRing is the full rotation state, persisted as a single document rather than key-by-key - it's small, and rotation is a rare, deliberate action, not something that needs fine-grained concurrent writes.

func NewKeyRing

func NewKeyRing() (*KeyRing, error)

NewKeyRing bootstraps a fresh keyring with one key, current. Called exactly once per deployment, the first time a proxy starts up and finds nothing in Vault yet.

func (*KeyRing) Current

func (kr *KeyRing) Current() (KeyEntry, ed25519.PrivateKey, error)

Current returns the entry (and derived private key) new roots should be signed with.

func (*KeyRing) Prune

func (kr *KeyRing) Prune(now time.Time)

Prune drops any superseded key whose grace period has fully elapsed - no session signed under it can possibly still be alive. Safe to call on every load; a no-op most of the time.

func (*KeyRing) Rotate

func (kr *KeyRing) Rotate() (newKeyID string, err error)

Rotate generates a fresh signing key and makes it current. The previously-current key is marked superseded (starting its grace-period clock), not removed - tokens it already signed keep verifying at any verifier that still has it cached until Prune eventually drops it.

func (*KeyRing) ServablePublicKeys

func (kr *KeyRing) ServablePublicKeys() ([]PublicKeyEntry, error)

ServablePublicKeys is what GET /biscuit-keys hands a verifier: every key still inside its grace period (or current), public half only.

type KeyRingStore

type KeyRingStore interface {
	// Load returns (nil, nil) if no keyring has been created yet - the
	// caller bootstraps one and Saves it, rather than Load doing that
	// implicitly, so "no keyring yet" and "storage error" stay distinguishable.
	Load(ctx context.Context) (*KeyRing, error)
	Save(ctx context.Context, kr *KeyRing) error
}

KeyRingStore persists the whole keyring as one document. Vault's KV v2 engine is the real store (below) - not Transit, since here Vault is just holding a document for us rather than performing a signing operation itself. Tests use an in-memory fake so go test never depends on a live Vault server, the same pattern as FakeSeedMinter standing in for Vault elsewhere in this package.

type LocalSeedMinter

type LocalSeedMinter struct{}

LocalSeedMinter generates a session seed from crypto/rand with no Vault call at all. Tests use it so `go test` never depends on a live Vault server; zero-config mode uses it for real.

func (LocalSeedMinter) MintSessionSeed

func (LocalSeedMinter) MintSessionSeed(ctx context.Context, sessionKey string) (string, error)

type PublicKeyEntry

type PublicKeyEntry struct {
	ID           string `json:"id"`
	PublicKeyHex string `json:"public_key_hex"`
	Current      bool   `json:"current"`
}

PublicKeyEntry is the public, servable half of a KeyEntry - what GET /biscuit-keys hands out. Never carries SeedHex.

type Root

type Root interface {
	Attenuate(tool, resource string, ttl time.Duration) (Token, error)
	Expired() bool
}

Root is attenuated locally, per call, with no network round trip.

type RootIssuer

type RootIssuer interface {
	MintRoot(ctx context.Context, sessionKey, delegatedUser, delegatedRole string) (Root, error)
}

RootIssuer mints a session-scoped Root. Callers (internal/handler) cache the Root per session key and only call MintRoot again once it expires - this is the boundary where the real Vault network cost is paid.

type SeedMinter

type SeedMinter interface {
	MintSessionSeed(ctx context.Context, sessionKey string) (seedHex string, err error)
}

SeedMinter sources the per-session random seed embedded in the root biscuit's authority facts. Interface so tests never need a live Vault server - only cmd/rinwall's real wiring uses VaultSeedMinter.

type Token

type Token struct {
	Value     string
	ExpiresAt time.Time
}

type VaultAWSCredentialMinter

type VaultAWSCredentialMinter struct {
	// contains filtered or unexported fields
}

VaultAWSCredentialMinter calls Vault's AWS secrets engine (aws/sts/<role>) - a real network round trip that executes a real sts:AssumeRole against AWS, using Vault's own configured root credential (itself scoped to nothing more than sts:AssumeRole on the specific role(s) a Vault AWS role names - docs/REFERENCE.md's dynamic-secrets section shows how that credential is itself provisioned narrowly).

func NewVaultAWSCredentialMinter

func NewVaultAWSCredentialMinter(client *vaultapi.Client) *VaultAWSCredentialMinter

func (*VaultAWSCredentialMinter) MintAWSCredential

func (m *VaultAWSCredentialMinter) MintAWSCredential(ctx context.Context, awsRole string) (AWSCredential, error)

func (*VaultAWSCredentialMinter) RevokeAWSCredential

func (m *VaultAWSCredentialMinter) RevokeAWSCredential(ctx context.Context, leaseID string) error

type VaultDBCredentialMinter

type VaultDBCredentialMinter struct {
	// contains filtered or unexported fields
}

VaultDBCredentialMinter calls Vault's database secrets engine (database/creds/<role>) - a real network round trip that executes a real CREATE ROLE statement against the configured database backend, exactly once per session (see sessioncache-style caching in the handler layer for the per-call reuse pattern this is meant to support).

func NewVaultDBCredentialMinter

func NewVaultDBCredentialMinter(client *vaultapi.Client) *VaultDBCredentialMinter

func (*VaultDBCredentialMinter) MintDBCredential

func (m *VaultDBCredentialMinter) MintDBCredential(ctx context.Context, dbRole string) (DBCredential, error)

func (*VaultDBCredentialMinter) RevokeDBCredential

func (m *VaultDBCredentialMinter) RevokeDBCredential(ctx context.Context, leaseID string) error

RevokeDBCredential ends a credential's lease early - Vault responds by running the database plugin's real revocation statements (a DROP ROLE, for the postgresql-database-plugin), not just forgetting about it on Vault's side. Used when a session ends deliberately, rather than only ever waiting out the lease TTL.

type VaultKeyRingStore

type VaultKeyRingStore struct {
	// contains filtered or unexported fields
}

VaultKeyRingStore stores the keyring under one path in Vault's KV v2 secrets engine (the default `secret/` mount in a dev server already running for this project). This is the step up from "signing key in a config.yaml field": the private key material now lives only in Vault, fetched into proxy memory at startup/rotation, never written to disk alongside the rest of the deployment's config.

func NewVaultKeyRingStore

func NewVaultKeyRingStore(client *vaultapi.Client, mountPath, secretPath string) *VaultKeyRingStore

func (*VaultKeyRingStore) Load

func (s *VaultKeyRingStore) Load(ctx context.Context) (*KeyRing, error)

func (*VaultKeyRingStore) Save

func (s *VaultKeyRingStore) Save(ctx context.Context, kr *KeyRing) error

type VaultSeedMinter

type VaultSeedMinter struct {
	// contains filtered or unexported fields
}

VaultSeedMinter calls Vault's Transit engine once per session (transit/datakey/plaintext/<key>) - a real network round trip that costs real latency (hundreds of milliseconds), paid once per session, and leaves a real entry in Vault's own audit log.

func NewVaultSeedMinter

func NewVaultSeedMinter(client *vaultapi.Client, transitKey string) *VaultSeedMinter

func (*VaultSeedMinter) MintSessionSeed

func (m *VaultSeedMinter) MintSessionSeed(ctx context.Context, sessionKey string) (string, error)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL