secretbroker

package
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package secretbroker brokers scoped, time-limited credential grants to execution backends.

The problem it replaces: pkg/secret is a flat key→value map with an all-or-nothing EnvLines(). Every workload that got any secret got all of them, forever, as plain environment variables. That is workable when the only executor is "a child process of the control plane owned by the same person who wrote the secrets down". It is not workable once workloads run in containers, on remote edge devices, and on behalf of different tenants: a single leaked GitHub PAT then reaches every repository the operator can touch, from a machine the operator does not control.

The model here is three-part:

Secret   what the credential is (kind + sealed payload). Never leaves
         this package in plaintext except inside a Material.
Grant    who may use it, under what constraints, until when. Constraints
         are kind-specific and enforced at delivery, not documented and
         hoped for.
Lease    a short-lived, minimized materialisation of the grants that
         match one (executor, project) subject. Executors receive leases;
         they never receive the store.

Enforcement, honestly scoped. Three of the constraint kinds are enforced by construction, because the broker rewrites the payload before it is delivered and a narrower payload cannot be widened by its holder:

  • kubeconfig is rewritten to contain only the allowed contexts, their referenced clusters/users, and an allowed namespace pinned on each;
  • registry credentials are filtered to the allowed registries;
  • env secrets are filtered to the allowed keys.

github_pat is enforced at the point of use rather than by construction: GitHub has no API to narrow an already-issued PAT, so instead of exporting a bare GITHUB_TOKEN the broker emits a git credential helper that releases the token only for paths matching the allowlist (see githubpat.go). A bare GITHUB_TOKEN is exported only when the allowlist is explicitly "*".

egress_proxy carries its allowlist to the executor, whose network policy is the enforcement point; in-process callers gate on Constraints.AllowsHost. This is the one kind whose enforcement lives outside this package, and it is called out here so nobody mistakes it for the kubeconfig guarantee.

Fail-closed is the rule throughout: an unparseable constraint, an unsatisfiable one, an expired or revoked grant, or a payload that minimizes to nothing all produce a denial and an audit row — never a wider credential.

Index

Constants

View Source
const (
	// LegacyKeyID names material sealed by the pre-envelope construction:
	// ciphertext directly under the passphrase-derived key, with no wrapped
	// DEK. Migration 0019 stamps it onto every pre-existing row so "sealed
	// the old way" is an explicit state rather than an inference from an
	// empty column.
	LegacyKeyID = "legacy"

	// KEK lifecycle states. Exactly one KEK is primary (new material is
	// sealed under it); any number may be active (openable, still referenced
	// by rows); retired KEKs have had their salt destroyed and can no longer
	// be derived at all.
	KEKStatePrimary = "primary"
	KEKStateActive  = "active"
	KEKStateRetired = "retired"
)
View Source
const (
	SetSecrets  = "secrets"
	SetSessions = "sessions"
)

Sealed-set names. They are part of every envelope's associated data, so they are a wire format: changing one makes existing rows in that set unopenable.

Namespacing by set is not cosmetic. Without it, an attacker with database write access could copy a secret's (wrapped_dek, payload) into a sessions row whose id equals that secret's id, and the hub would authenticate it happily and then POST the kubeconfig to the identity provider as if it were a refresh token — an exfiltration channel out of the sealed store to an external host. Row ids alone cannot prevent that, because they are chosen by whoever writes the row.

View Source
const (
	RotationRunning     = "running"
	RotationCompleted   = "completed"
	RotationInterrupted = "interrupted"
	RotationFailed      = "failed"
)

Rotation states.

View Source
const DefaultGrantTTL = 24 * time.Hour

DefaultGrantTTL is used when a grant is created without an explicit TTL from a path that requires one.

View Source
const DefaultMaxLeaseTTL = 15 * time.Minute

DefaultMaxLeaseTTL bounds how long any single lease is valid, regardless of how long its grants last.

The split between grant TTL and lease TTL is the point of the design. An operator reasonably grants CI access to a repository for a day; it does not follow that a container should hold a usable token for a day. Fifteen minutes with renewal keeps long runs working while bounding what a compromised or forgotten executor still holds — and because Renew re-evaluates every grant, a revocation lands within one lease period rather than at the end of the grant.

View Source
const DefaultRotationBatch = 128

DefaultRotationBatch is how many rows are listed per round.

Small enough that an interrupted rotation loses almost nothing and that the rotator never holds a large slice of ciphertext, large enough that a big registry does not turn into thousands of round trips.

View Source
const EnvPassphraseKey = "CLOOP_SECRET_KEY"

EnvPassphraseKey is the environment variable holding the passphrase that protects sealed payloads. It is the same variable pkg/secret uses, so an existing deployment's key keeps working across the migration.

View Source
const GitHubUsername = "x-access-token"

GitHubUsername is the basic-auth username GitHub expects alongside a PAT. GitHub ignores the value for a PAT but requires a non-empty one, and "x-access-token" is the name its own tooling uses.

View Source
const MaxLocalRepos = 32

MaxLocalRepos bounds how many repositories one grant may open.

The limit is legibility, not resource use. A grant matching forty checkouts has stopped describing "these repositories" and started describing "the machine", which is the thing the allowlist exists to prevent someone doing by accident with a broad glob.

View Source
const SandboxLeaseRoot = "/run/cloop"

SandboxLeaseRoot is the parent directory a lease's files are delivered under inside an executor that does not share the hub's filesystem.

/run is the conventional place for ephemeral runtime state and is a tmpfs on every systemd host, which is the same property the hub's /dev/shm is chosen for. A driver that cannot write there relocates and rewrites the environment; see executor.Spec.RelocateSecrets.

View Source
const SandboxRepoRoot = "/repos"

SandboxRepoRoot is where granted repositories appear inside a sandbox.

A fixed path rather than a configurable one so a harness, a setup script and a README can all name it without knowing which executor they landed on. It is outside /workspace on purpose: the workspace is bind-mounted whole by the container driver, so a target underneath it would be shadowed by that mount on some drivers and not others.

Variables

View Source
var (
	// ErrInvalidKind: the secret kind is not one of the known kinds.
	ErrInvalidKind = errors.New("secretbroker: invalid secret kind")
	// ErrInvalidSecret: the secret failed structural validation.
	ErrInvalidSecret = errors.New("secretbroker: invalid secret")
	// ErrInvalidGrant: the grant failed structural validation.
	ErrInvalidGrant = errors.New("secretbroker: invalid grant")
	// ErrInvalidSubject: the grant subject could not be parsed or is empty.
	ErrInvalidSubject = errors.New("secretbroker: invalid subject")
	// ErrInvalidConstraint: a constraint pattern is malformed, unsafe to
	// embed, or missing where its kind requires one.
	ErrInvalidConstraint = errors.New("secretbroker: invalid constraint")

	// ErrSecretNotFound: no secret with that ID or name exists.
	ErrSecretNotFound = errors.New("secretbroker: secret not found")
	// ErrGrantNotFound: no grant with that ID exists.
	ErrGrantNotFound = errors.New("secretbroker: grant not found")
	// ErrLeaseNotFound: the lease ID is unknown or already released.
	ErrLeaseNotFound = errors.New("secretbroker: lease not found")
	// ErrDuplicateName: a secret with that name already exists.
	ErrDuplicateName = errors.New("secretbroker: secret name already in use")

	// ErrGrantExpired: the grant's TTL elapsed.
	ErrGrantExpired = errors.New("secretbroker: grant expired")
	// ErrGrantRevoked: the grant was revoked.
	ErrGrantRevoked = errors.New("secretbroker: grant revoked")
	// ErrLeaseExpired: the lease's TTL elapsed; Renew or re-Lease.
	ErrLeaseExpired = errors.New("secretbroker: lease expired")

	// ErrRepoDenied: the repository is outside the grant's allowlist, or
	// could not be normalised into owner/repo form.
	ErrRepoDenied = errors.New("secretbroker: repository denied")
	// ErrNamespaceDenied: the Kubernetes namespace is outside the allowlist.
	ErrNamespaceDenied = errors.New("secretbroker: namespace denied")
	// ErrHostDenied: the host is outside the egress allowlist, or could not
	// be normalised.
	ErrHostDenied = errors.New("secretbroker: host denied")

	// ErrMinimizedEmpty: minimising the payload against the grant's
	// constraints left nothing to deliver. This is a denial, not an empty
	// success — delivering an empty kubeconfig would look like a working
	// credential that mysteriously cannot reach anything.
	ErrMinimizedEmpty = errors.New("secretbroker: nothing remains after minimization")
	// ErrMalformedPayload: the stored payload is not in the shape its kind
	// requires (e.g. a kubeconfig that is not valid YAML).
	ErrMalformedPayload = errors.New("secretbroker: malformed secret payload")

	// ErrNoKey: CLOOP_SECRET_KEY is unset, so payloads can be neither
	// sealed nor opened.
	ErrNoKey = errors.New("secretbroker: CLOOP_SECRET_KEY is not set")
	// ErrSealFailed: encryption or decryption failed (wrong key, or a
	// corrupt/tampered envelope — AES-GCM cannot tell you which).
	ErrSealFailed = errors.New("secretbroker: seal/unseal failed")

	// ErrKeyRetired: the material names a KEK whose salt was destroyed. The
	// material is unrecoverable and the credential must be re-minted.
	ErrKeyRetired = errors.New("secretbroker: sealing key retired")
	// ErrKeyUnavailable: the KEK exists but cannot be derived from the
	// current CLOOP_SECRET_KEY. The material is intact; the passphrase is
	// wrong.
	ErrKeyUnavailable = errors.New("secretbroker: sealing key unavailable")
	// ErrKeyUnknown: the material names a KEK this hub has no record of —
	// a database from another hub, or a partially restored backup.
	ErrKeyUnknown = errors.New("secretbroker: unknown sealing key")
	// ErrKeyInUse: the KEK cannot be retired because sealed material still
	// references it, or because it is the primary.
	ErrKeyInUse = errors.New("secretbroker: sealing key still in use")
	// ErrRotationFailed: a rotation could not rewrap every row. Rotation is
	// resumable, so this means "run it again", not "start over".
	ErrRotationFailed = errors.New("secretbroker: key rotation incomplete")
)

Sentinel errors. Callers match with errors.Is; every constructor in this package wraps one of these with %w and adds detail.

The denial errors (ErrRepoDenied, ErrNamespaceDenied, ErrHostDenied, ErrGrantExpired, ErrGrantRevoked) are deliberately distinct from the validation errors: a denial is a security event that must reach the audit log, while a validation error is a malformed request that never became one.

Functions

func AADFor

func AADFor(set, rowID string) string

AADFor builds the associated data for one row of one sealed set. Every seal, open, and rewrap of that row must use the identical value.

func KubeconfigSummary

func KubeconfigSummary(minimized []byte) string

KubeconfigSummary describes a minimized kubeconfig for audit and CLI output: which contexts and namespaces survived. It never includes cluster endpoints' credentials, only names.

func MinimizeKubeconfig

func MinimizeKubeconfig(raw []byte, c Constraints) ([]byte, error)

MinimizeKubeconfig rewrites a kubeconfig to contain only what the grant's constraints permit, and returns the reduced document.

This is the strongest enforcement in the package, because it is subtractive: the executor receives a kubeconfig that has no credentials for the clusters it may not reach and no context naming a namespace it may not touch. Nothing it can do with that document widens it back out. That is a different and better guarantee than handing over the full kubeconfig alongside a policy note.

Rules:

  • a context survives only if Constraints.AllowsContext says so;
  • when the grant lists namespaces, each surviving context is pinned to an allowed namespace — its own if that is allowed, otherwise the first concrete (non-glob) namespace in the allowlist;
  • a context whose namespace is disallowed and which cannot be pinned (the allowlist is globs only) is dropped rather than guessed at;
  • clusters and users not referenced by a surviving context are removed, so their certificates and tokens never leave the control plane;
  • current-context is repointed at a surviving context;
  • if nothing survives, the result is ErrMinimizedEmpty — a denial, not an empty file that would look like a working credential.

func NewLeaseDirPath

func NewLeaseDirPath(baseDir string) (string, error)

NewLeaseDirPath returns an unused lease-directory path under baseDir — without creating it, and without writing anything.

It exists so a caller can record its intent to materialise *before* any plaintext lands on disk. Materialize used to pick the directory itself with MkdirTemp, which left no way to write a durable "a lease directory is about to exist at this path" row first: a hub killed between the mkdir and the row came back up with a credential directory nothing knew about, and the startup sweep had nothing to reconcile it against. /dev/shm is a tmpfs and clears on reboot, but a hub *process* restart is the common case and clears nothing.

Splitting the naming from the creation makes the ordering possible:

dir, _ := NewLeaseDirPath("")   // nothing on disk yet
db.PutSecretLeaseDir(...)       // intent recorded
lease.MaterializeAt(dir)        // plaintext appears

A crash after the row exists leaves a trace the sweep can act on; a crash before it leaves nothing at all. There is no window in between.

baseDir may be empty to use a tmpfs. The 96-bit suffix matches lease IDs, so two hubs sharing one /dev/shm cannot collide.

func NormalizeHost

func NormalizeHost(host string) (string, error)

NormalizeHost reduces a host, host:port, or URL authority to a bare lowercase hostname. Anything carrying a path, credentials, or a wildcard is rejected: those are the shapes an attacker uses to make "evil.com" look like "api.example.com" to a naive matcher.

func NormalizeProjectID

func NormalizeProjectID(id string) string

NormalizeProjectID canonicalises a project identifier so that a grant written as "/srv/app/" and a lease requested for "/srv/app" agree. Absolute paths are cleaned; anything else (an opaque project ID, or the "*" wildcard) is passed through trimmed. Matching is exact after this — no prefix or basename fuzziness, which would let /srv/app-staging pick up /srv/app's credentials.

func NormalizeRepo

func NormalizeRepo(repo string) (string, error)

NormalizeRepo canonicalises the many ways a repository gets named into the single "owner/repo" form the allowlist is written in, so that "https://github.com/Org/Repo.git" and "org/repo" are the same subject.

Anything that does not reduce to exactly one owner and one repo is an error, not a best guess: a caller that hands us something unrecognisable must be denied, not matched against a partially-parsed string.

func ParseLocalRepoRoot

func ParseLocalRepoRoot(payload []byte) (string, error)

ParseLocalRepoRoot validates a local_repo payload.

Exported because storing one of these secrets should fail at the API with a usable message — "that path does not exist on the hub" is a typo the operator can fix in the dialog, and finding out at lease time instead means finding out during someone else's run.

func RedactString

func RedactString(s string) string

RedactString removes credential-shaped substrings from s.

Two classes are handled: known token prefixes, and URL userinfo ("https://user:password@host"), which is how an egress_proxy secret would leak if its URL ended up in an error message.

func SafeRef

func SafeRef(ref string) string

SafeRef renders a secret reference for an error message or an audit record without republishing it.

A "no such secret" error is one of the few places a user-supplied string is echoed verbatim, and the single most common way to reach it is to paste the credential where its *name* belongs. That mistake is silent — the operator sees a not-found error, retries correctly, and never learns that the first attempt wrote the live token into the audit log, which is then shipped off-box and retained for a year.

Redaction alone is not enough: it recognises known token prefixes, and a kubeconfig token or a registry auth blob has no prefix to recognise. So the reference is also truncated, which is the part that generalises to credential shapes nobody has enumerated yet.

func SandboxLeaseDir

func SandboxLeaseDir(leaseID string) string

SandboxLeaseDir returns the directory a lease's credential files take inside a sandbox. The final element carries leaseDirPrefix so the agent's confinement rule recognises it as lease-owned.

func ValidateName

func ValidateName(name string) error

nameCharset restricts secret names to characters that are safe to embed in audit payloads, CLI output, env var derivations, and file names without escaping. Rejecting at the door is cheaper than escaping at every sink.

Types

type Action

type Action string

Action names the brokered operation an audit event describes.

const (
	ActionMint        Action = "secret.mint"
	ActionDeleteSec   Action = "secret.delete"
	ActionGrant       Action = "secret.grant"
	ActionRevoke      Action = "secret.revoke"
	ActionLease       Action = "secret.lease"
	ActionRenew       Action = "secret.renew"
	ActionRelease     Action = "secret.release"
	ActionAccessCheck Action = "secret.access_check"

	// Lease revocation actions (Task 20178). These are separate from
	// ActionRelease because releasing a lease and revoking one answer
	// different questions. A release is the ordinary end of a workload's
	// credential lifetime; a revocation is somebody deciding, mid-run, that
	// an executor may no longer hold something it already has.
	//
	// All three exist because a revocation is not one event. It is sent,
	// and then it either lands or it does not, possibly minutes later when
	// an offline device reconnects. Collapsing them into one row would make
	// the trail claim a credential was withdrawn at a moment when it
	// demonstrably still worked.
	ActionLeaseRevokeSent   Action = "lease.revoke_sent"
	ActionLeaseRevokeAcked  Action = "lease.revoke_acked"
	ActionLeaseRevokeFailed Action = "lease.revoke_failed"

	// Egress actions come from pkg/egressbroker, which brokers the hub's
	// Internet connection as a fourth grantable resource alongside GitHub
	// repositories, PATs, and Kubernetes clusters.
	//
	// They share this audit trail rather than opening a second one on
	// purpose: "what did this executor reach, and with whose authority" is
	// one question, and answering it from two hash chains that can disagree
	// about ordering would be strictly worse than answering it from one.
	ActionEgressGrant   Action = "egress.grant"
	ActionEgressRevoke  Action = "egress.revoke"
	ActionEgressRedeem  Action = "egress.redeem"
	ActionEgressConnect Action = "egress.connect"
	ActionEgressRequest Action = "egress.request"
	ActionEgressClose   Action = "egress.close"
)

type Auditor

type Auditor interface {
	Audit(ev Event)
}

Auditor receives brokered-operation events. Implementations must not block the broker: an audit sink that is slow or down must not stop a workload from starting, so the production adapter writes best-effort and swallows its own errors, exactly as pkg/statedb's audit emitters do.

type AuditorFunc

type AuditorFunc func(Event)

AuditorFunc adapts a function to the Auditor interface.

func (AuditorFunc) Audit

func (f AuditorFunc) Audit(ev Event)

Audit implements Auditor.

type Broker

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

Broker issues scoped, expiring credential leases against a Store.

Safe for concurrent use.

func New

func New(store Store, opts ...Option) (*Broker, error)

New builds a Broker over store. Unless WithCipher is supplied, the payload key is derived from CLOOP_SECRET_KEY, and New fails if it is unset — a broker that cannot open payloads would otherwise fail later, at lease time, in the middle of somebody's run.

func (*Broker) CheckRepoAccess

func (b *Broker) CheckRepoAccess(ctx context.Context, r Requester, repo, actor string) error

CheckRepoAccess is the in-process enforcement point for github grants: it reports whether this requester may act on repo, and audits the decision.

cloop's own GitHub call sites (pkg/github, pkg/githubsync) route through here so that a repository outside the allowlist is refused before a request is made, rather than relying on the credential helper alone.

func (*Broker) DeleteSecret

func (b *Broker) DeleteSecret(ctx context.Context, ref, actor string) error

DeleteSecret removes a secret and revokes every grant that pointed at it. Leaving grants behind would leave rows that resolve to nothing and read, in a grant listing, as still-live access.

func (*Broker) DescribeSecret

func (b *Broker) DescribeSecret(ref string) (Secret, error)

DescribeSecret resolves a secret by ID or name. The payload stays sealed: this answers "does this reference name something, and of what kind", which is what a caller validating a grant before creating it needs.

It exists so that path does not have to re-implement reference resolution against ListSecrets, where a subtly different name match would mean a grant validated against one secret and then created against another.

func (*Broker) Grant

func (b *Broker) Grant(ctx context.Context, req GrantRequest) (Grant, error)

Grant authorises a subject to use a secret under constraints.

func (*Broker) Keyring

func (b *Broker) Keyring() *Keyring

Keyring returns the broker's key registry, or nil when it was built over a store with no registry. Callers that rotate must handle nil rather than assume every broker can.

func (*Broker) Lease

func (b *Broker) Lease(ctx context.Context, executorID, projectID string) (*Lease, error)

Lease issues the credentials this executor may hold for this project.

It returns only grants whose subject matches the requester and which are neither expired nor revoked, each minimized against its own constraints. An executor never sees the store, another project's grants, or any part of a payload that its constraints excluded.

A requester with no matching grants gets an empty lease, not an error: "this project has no secrets" is a normal state and must not fail a run.

func (*Broker) LeaseFor

func (b *Broker) LeaseFor(ctx context.Context, r Requester, actor string) (*Lease, error)

LeaseFor is Lease with executor labels (for SubjectLabel grants) and an explicit actor for the audit trail.

func (*Broker) ListGrants

func (b *Broker) ListGrants(f GrantFilter) ([]Grant, error)

ListGrants returns grants matching the filter, newest first.

func (*Broker) ListSecrets

func (b *Broker) ListSecrets() ([]Secret, error)

ListSecrets returns stored secrets. Payloads stay sealed.

func (*Broker) Mint

func (b *Broker) Mint(ctx context.Context, req MintRequest) (Secret, error)

Mint seals a payload and stores it as a new secret.

The payload is zeroed in the caller's slice on return so a mint site does not leave plaintext in a buffer the garbage collector may not touch for a while. Callers that still need the bytes must pass a copy.

func (*Broker) Release

func (b *Broker) Release(leaseID string)

Release drops a lease's server-side record. Callers should Close the Mount as well; Release only stops the lease from being renewable.

func (*Broker) Renew

func (b *Broker) Renew(ctx context.Context, leaseID string) (*Lease, error)

Renew re-issues a lease for the same requester.

It deliberately re-evaluates every grant from the store rather than extending the existing materials. That is what makes revocation effective within one lease period: a grant revoked a minute ago is gone from the renewed lease even though the executor and project are unchanged.

func (*Broker) Revoke

func (b *Broker) Revoke(ctx context.Context, grantID, actor string) error

Revoke marks a grant unusable. It takes effect on the next Lease or Renew: credentials already materialised into a running workload's tmpfs stay there until that lease expires, which is exactly the window the short lease TTL exists to bound.

func (*Broker) SweepExpired

func (b *Broker) SweepExpired() (expired, live map[Kind]int)

SweepExpired drops lease records whose TTL has passed and reports what is left, tallied by credential kind.

It exists for two reasons that happen to have the same implementation.

The metrics reason: cloop_secret_leases_live has to mean "leases that would be honoured right now", and the only way to know that is to apply the same expiry test the broker applies. Counting map entries would report leases that expired hours ago as live.

The correctness reason: before this, a lease record was removed only by Renew or Release. A lease that simply expired — the ordinary end of a task that was killed, or an executor that went away without releasing — left its leaseState in the map for the lifetime of the process. On a busy hub that is an unbounded map keyed by lease ID, which is to say a slow leak that a tenant can drive. Expiry now collects them.

The returned maps are (expired, live) counts by kind.

type Cipher

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

Cipher seals and opens secret payloads with AES-256-GCM.

Unlike pkg/secret, which re-derives its key from a fresh salt on every save, the broker derives once from a salt persisted in the store and caches the key. That matters here: pkg/secret rewrites one file wholesale, while the broker opens individual payloads on every lease. At 200 000 SHA-256 rounds, per-payload derivation would put roughly a fifth of a second of CPU between an executor and each of its credentials. Per-payload randomness comes from the GCM nonce instead, which is what it is for.

func NewCipher

func NewCipher(store Store) (*Cipher, error)

NewCipher derives the payload key from the CLOOP_SECRET_KEY passphrase and the store-wide salt. The salt is created on first use and persisted, so the same passphrase yields the same key across restarts.

func NewCipherWithKey

func NewCipherWithKey(key []byte) (*Cipher, error)

NewCipherWithKey builds a Cipher from a raw 32-byte key. Used by tests and by callers that manage key material themselves.

func NewLegacyCipher

func NewLegacyCipher(passphrase string, salt []byte) (*Cipher, error)

NewLegacyCipher rebuilds the pre-envelope cipher from a passphrase and the store-wide salt.

It is exported for exactly two callers: the tests that verify pre-envelope rows still open after migration 0019, and any recovery tooling that has to read a database whose broker_meta salt survived but whose registry did not. Nothing on the serving path uses it — the Keyring loads the legacy cipher itself, and only when a salt is already present.

func (*Cipher) OpenEnvelope

func (c *Cipher) OpenEnvelope(_ string, env Envelope) ([]byte, error)

OpenEnvelope lets a plain Cipher satisfy sealer.

func (*Cipher) Seal

func (c *Cipher) Seal(plaintext []byte) ([]byte, error)

Seal encrypts plaintext, returning nonce||ciphertext||tag.

func (*Cipher) SealFor

func (c *Cipher) SealFor(_ string, plaintext []byte) (Envelope, error)

SealFor lets a plain Cipher satisfy sealer. It produces legacy-shaped envelopes: no wrapped DEK, ciphertext directly under the one key.

Associated data is ignored, because the legacy construction has nowhere to put it. That is a real (small) difference in the guarantees a Cipher-backed broker offers versus a Keyring-backed one, and it is the reason production paths use a Keyring: pkg/secretstore implements KeyStore, so every hub gets envelope encryption and the row binding that comes with it.

func (*Cipher) Unseal

func (c *Cipher) Unseal(envelope []byte) ([]byte, error)

Unseal decrypts an envelope produced by Seal. A wrong key and a tampered envelope are indistinguishable — both surface as ErrSealFailed, which is the correct amount of information to give a caller.

type Constraints

type Constraints struct {
	// Repos is an owner/repo glob allowlist for github_pat and github_app.
	// Patterns match case-insensitively; "*" alone allows every repository.
	Repos []string `json:"repos,omitempty"`
	// Permissions is the permission set a github credential may exercise
	// ("contents:read", "pull_requests:write"). Enforced at cloop's own
	// GitHub call sites via AllowsPermission; GitHub cannot narrow an
	// already-issued PAT server-side.
	Permissions []string `json:"permissions,omitempty"`
	// Namespaces is the Kubernetes namespace allowlist for kubeconfig.
	Namespaces []string `json:"namespaces,omitempty"`
	// Contexts is the kubeconfig context allowlist. The delivered
	// kubeconfig contains only these contexts.
	Contexts []string `json:"contexts,omitempty"`
	// Hosts is the allowed-host list for egress_proxy. A leading "*."
	// matches subdomains only, not the bare domain.
	Hosts []string `json:"hosts,omitempty"`
	// Registries is the container-registry allowlist for registry secrets.
	Registries []string `json:"registries,omitempty"`
	// EnvKeys restricts which keys of an env secret are delivered. Empty
	// means every key in the secret, which is safe because an env secret's
	// keys *are* its scope — there is nothing wider to fall open to.
	EnvKeys []string `json:"env_keys,omitempty"`
	// Writable makes a local_repo grant read-write. It is the one constraint
	// that widens rather than narrows, so it is a bool that defaults to the
	// safe reading: a grant that says nothing delivers a read-only mount.
	//
	// Read-only is the useful default rather than a cautious one. The common
	// case is a sandbox that needs to *read* a developer's checkout — build
	// against it, grep it, copy from it — and a read-only bind means a
	// runaway harness cannot rewrite the history of a repository that exists
	// nowhere else. A project that genuinely needs to commit asks for it.
	Writable bool `json:"writable,omitempty"`
}

Constraints narrow what a granted secret may be used for. Which fields apply depends on the secret's Kind; ValidateFor rejects a grant whose constraints do not gate its kind, so an under-specified grant is a creation-time error rather than a delivery-time surprise.

The wildcard "*" is always spelled out. An empty list on a gating dimension is rejected by ValidateFor rather than treated as "allow all" — the single most common way allowlists fail open is an empty slice reading as "no restrictions", and that reading is unavailable here.

func (Constraints) AllowsContext

func (c Constraints) AllowsContext(name string) bool

AllowsContext reports whether a kubeconfig context name is permitted. A grant that lists namespaces but no contexts permits every context (the namespace pin is then the constraint); a grant that lists contexts restricts to exactly those.

func (Constraints) AllowsEnvKey

func (c Constraints) AllowsEnvKey(key string) bool

AllowsEnvKey reports whether an env secret's key may be delivered. An empty EnvKeys list allows every key the secret contains — see the field comment for why that is not a fail-open.

func (Constraints) AllowsHost

func (c Constraints) AllowsHost(host string) bool

AllowsHost reports whether host is in the egress allowlist.

func (Constraints) AllowsNamespace

func (c Constraints) AllowsNamespace(ns string) bool

AllowsNamespace reports whether ns is permitted. An empty allowlist denies: ValidateFor guarantees a kubeconfig grant has namespaces or contexts, and where it has only contexts, namespace checks are not the gate.

func (Constraints) AllowsPermission

func (c Constraints) AllowsPermission(perm string) bool

AllowsPermission reports whether a github permission is within the grant's permission set. An empty set permits nothing beyond read: an operator who does not enumerate permissions has not authorised any write.

func (Constraints) AllowsRegistry

func (c Constraints) AllowsRegistry(reg string) bool

AllowsRegistry reports whether a container registry host is permitted. Registry names are host-shaped, so they reuse the host matcher's subdomain semantics after normalisation.

func (Constraints) AllowsRepo

func (c Constraints) AllowsRepo(repo string) bool

AllowsRepo reports whether repo is covered by the allowlist.

Matching is per-segment: path.Match's "*" does not cross "/", so "org/*" covers org/tool but not a three-segment path, and a bare "*" is special- cased to mean every repository. Comparison is case-insensitive because GitHub owner and repo names are.

func (Constraints) CheckHost

func (c Constraints) CheckHost(host string) error

CheckHost is AllowsHost with a reason attached.

"*.example.com" matches subdomains only — api.example.com yes, example.com no. Requiring the bare domain to be listed separately keeps "allow the API subdomains" from silently also allowing the apex, which is often a different service on a different box.

func (Constraints) CheckRepo

func (c Constraints) CheckRepo(repo string) error

CheckRepo is AllowsRepo with the denial reason attached, so callers can put a cause in the audit log instead of a bare false.

func (Constraints) Summary

func (c Constraints) Summary() string

Summary renders the constraints as a short, sorted, human-readable string for CLI listings and audit payloads. It contains only allowlist patterns, never payload material.

func (Constraints) ValidateFor

func (c Constraints) ValidateFor(kind Kind) error

ValidateFor checks that these constraints are well-formed *and* that they actually gate the given kind. A github_pat grant with no repo allowlist is rejected here: there is no safe default for "which repositories may this token touch", so the operator has to say, even if what they say is "*".

type Decision

type Decision string

Decision is the outcome recorded for every brokered operation.

const (
	// DecisionAllow: the operation succeeded and credentials were (or will
	// be) delivered.
	DecisionAllow Decision = "allow"
	// DecisionDeny: the operation was refused. The Reason says why.
	DecisionDeny Decision = "deny"
)

type DeliveredFile

type DeliveredFile struct {
	GrantID    string
	SecretName string
	Kind       Kind
	// Dir is the directory on the executor, mirroring Delivery.Dir so a
	// caller can build one flat list from several leases.
	Dir  string
	Name string
	Mode os.FileMode
	// Content is the plaintext.
	Content []byte
}

DeliveredFile is one credential file a Delivery is handing to a driver.

func (DeliveredFile) GoString

func (f DeliveredFile) GoString() string

GoString mirrors String so %#v is redacted too.

func (DeliveredFile) String

func (f DeliveredFile) String() string

String redacts the content, so a `%v` on a delivery cannot log a credential.

type Delivery

type Delivery struct {
	// Dir is where the workload will find the files. It is a path on the
	// executor, not on this host.
	Dir string
	// contains filtered or unexported fields
}

Delivery is a lease rendered for someone else's filesystem: the environment to hand the workload, the per-grant attribution, and the file bytes the driver must place at Dir.

It is the Mount's counterpart, with the same accessors and one difference that is the point of the type: nothing here has touched a disk, so Close zeroes buffers rather than unlinking files.

func (*Delivery) Bindings

func (d *Delivery) Bindings() []LeaseBinding

Bindings returns the per-grant attribution: names and paths, never values.

func (*Delivery) Close

func (d *Delivery) Close() error

Close zeroes the buffers holding the plaintext and drops the rest. It is idempotent, so it is safe in a defer beside an explicit call.

Zeroing is not a guarantee — Go's garbage collector may already have copied the bytes during a slice growth, and nothing here can reach that copy — but it bounds the window in which a heap dump of a long-lived control plane contains a credential it relayed hours ago.

func (*Delivery) Env

func (d *Delivery) Env() []string

Env returns the environment additions in "K=V" form, sorted.

func (*Delivery) Files

func (d *Delivery) Files() []DeliveredFile

Files returns the credential files for the driver to place, with their contents. Nil after Close.

The content is copied per call rather than aliased: a driver that base64s it into a frame must not be handed the buffer Close will zero underneath it.

func (*Delivery) Mounts

func (d *Delivery) Mounts() []RepoMount

Mounts returns the host repositories this lease opens.

type Envelope

type Envelope struct {
	KeyID      string
	WrappedDEK []byte
	Ciphertext []byte
}

Envelope is one piece of envelope-encrypted material.

A row stores all three parts. KeyID names the KEK that WrappedDEK is sealed under; Ciphertext is sealed under the DEK inside WrappedDEK. Rotation rewrites the first two and leaves the third byte-identical.

KeyID == LegacyKeyID (or "") means the pre-envelope construction: WrappedDEK is empty and Ciphertext is sealed directly under the passphrase-derived key. Those rows keep working until a rotation upgrades them.

func LegacyEnvelope

func LegacyEnvelope(ciphertext []byte) Envelope

LegacyEnvelope wraps a pre-envelope ciphertext for the open path.

func (Envelope) Empty

func (e Envelope) Empty() bool

Empty reports whether there is anything sealed here at all. A session with no refresh token stores an empty envelope rather than a sealed empty string, so "nothing to protect" and "protected nothing" stay distinct.

func (Envelope) IsLegacy

func (e Envelope) IsLegacy() bool

IsLegacy reports whether this envelope predates envelope encryption.

type Event

type Event struct {
	Time     time.Time `json:"time"`
	Action   Action    `json:"action"`
	Decision Decision  `json:"decision"`
	// Actor is who asked: an OIDC subject, "cli", "ui", or an executor ID.
	Actor string `json:"actor,omitempty"`
	// Subject is the grant subject the operation concerned, rendered in
	// the CLI's "--to" syntax.
	Subject string `json:"subject,omitempty"`
	// SecretID and SecretName identify the credential. The name is a
	// handle chosen by an operator, never the value.
	SecretID   string `json:"secret_id,omitempty"`
	SecretName string `json:"secret_name,omitempty"`
	Kind       Kind   `json:"kind,omitempty"`
	GrantID    string `json:"grant_id,omitempty"`
	LeaseID    string `json:"lease_id,omitempty"`
	ExecutorID string `json:"executor_id,omitempty"`
	ProjectID  string `json:"project_id,omitempty"`
	// Constraints is the allowlist summary that applied to the decision.
	Constraints string `json:"constraints,omitempty"`
	// ExpiresAt is the TTL stamp of the grant or lease involved.
	ExpiresAt time.Time `json:"expires_at,omitempty"`
	// Reason explains a denial, or annotates an allow ("2 grants matched").
	Reason string `json:"reason,omitempty"`

	// TaskID is the unit of work the decision was made for. Populated by
	// the egress broker, which knows it; the secret broker's leases are
	// per-executor rather than per-task and leave it empty.
	TaskID string `json:"task_id,omitempty"`
	// Host and Port name an egress destination. They are a hostname and a
	// port and nothing else — deliberately not a URL, because a URL has a
	// path and a query, and those are request contents rather than access
	// metadata.
	Host string `json:"host,omitempty"`
	Port int    `json:"port,omitempty"`
	// BytesUp and BytesDown are the session's transfer totals at the time of
	// the decision, from the workload's point of view.
	BytesUp   int64 `json:"bytes_up,omitempty"`
	BytesDown int64 `json:"bytes_down,omitempty"`
}

Event is one audit record. Every field on it is metadata about a credential: which one, for whom, under what constraints, and what was decided. None of them is, or may become, the credential itself.

This is enforced rather than asserted: Event has no field capable of carrying a payload, and Redact scrubs the free-text Reason before the event leaves the package, because Reason is built from error strings and an error string is the one place a payload could plausibly get spliced in.

func Redact

func Redact(ev Event) Event

Redact returns a copy of ev safe to persist.

It scrubs the Reason of anything that pattern-matches a credential. That may look like belt-and-braces given that no code path deliberately puts a payload in a Reason — and it is. The reason to do it anyway is that Reason is assembled from wrapped errors, and error wrapping is exactly how a payload leaks: someone one day writes fmt.Errorf("bad token %q: %w", tok, err) three packages down, and the audit log quietly becomes the place the credential is stored in plaintext forever. Scrubbing at the boundary means that mistake degrades to an unhelpful log line instead of a breach.

Auditors must call this (the broker calls it on every emission), and the storage adapter calls it again on the way in.

func (Event) Fields

func (ev Event) Fields() string

Fields renders an event as sorted key=value pairs, for text log sinks.

type File

type File struct {
	// Name is a bare file name — no directory separators. Materialize
	// re-checks this before writing, so a crafted name cannot escape the
	// lease directory.
	Name    string
	Content []byte
	Mode    os.FileMode
	// EnvVar, when set, receives the file's absolute path after
	// materialisation (KUBECONFIG, GIT_CONFIG_GLOBAL, DOCKER_CONFIG).
	EnvVar string
	// EnvIsDir points EnvVar at the containing directory rather than the
	// file, which is what DOCKER_CONFIG expects.
	EnvIsDir bool
}

File is one credential file to place in the lease directory.

type Grant

type Grant struct {
	ID       string `json:"id"`
	SecretID string `json:"secret_id"`
	// Scope is a free-form operator-facing label ("ci", "deploy") used for
	// grouping and filtering. It carries no authorisation weight — that is
	// entirely Subject plus Constraints — so an operator cannot widen a
	// grant by editing it.
	Scope       string      `json:"scope,omitempty"`
	Subject     Subject     `json:"subject"`
	Constraints Constraints `json:"constraints"`
	// ExpiresAt is when the grant stops being usable. Zero means no expiry,
	// which Validate permits only because the legacy import needs it; the
	// CLI always sets a TTL.
	ExpiresAt time.Time `json:"expires_at,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	CreatedBy string    `json:"created_by,omitempty"`
	RevokedAt time.Time `json:"revoked_at,omitempty"`
}

Grant authorises one subject to use one secret, under Constraints, until ExpiresAt.

func (Grant) Active

func (g Grant) Active(now time.Time) bool

Active reports whether the grant may be used at time now.

func (Grant) DenyReason

func (g Grant) DenyReason(now time.Time) string

DenyReason returns the audit-friendly reason this grant cannot be used at now, or "" when it is usable. Returning the reason rather than a bool is what lets every denial land in the audit log with a cause attached.

func (Grant) Validate

func (g Grant) Validate(kind Kind) error

Validate checks a Grant's structural invariants, including that its constraints make sense for the kind of secret it points at.

type GrantFilter

type GrantFilter struct {
	// Subject, when set, keeps only grants whose subject renders to this
	// string (exact match on the "--to" syntax).
	Subject string
	// SecretRef, when set, keeps only grants for that secret.
	SecretRef string
	// ActiveOnly drops expired and revoked grants.
	ActiveOnly bool
}

GrantFilter narrows ListGrants.

type GrantRequest

type GrantRequest struct {
	// SecretRef is a secret ID or name.
	SecretRef   string
	Subject     Subject
	Constraints Constraints
	Scope       string
	// TTL is the grant's lifetime. Zero means DefaultGrantTTL unless
	// NoExpiry is set.
	TTL time.Duration
	// NoExpiry creates a grant that never expires. Reserved for the legacy
	// import, where imposing a TTL on secrets that previously had none
	// would break running projects at an unpredictable moment.
	NoExpiry bool
	Actor    string
}

GrantRequest describes a grant to create.

type KEKRecord

type KEKRecord struct {
	ID    string
	Salt  string // hex-encoded KDF salt; emptied on retirement
	State string // primary | active | retired
	// CheckValue is kekCheckPlaintext sealed under this KEK.
	CheckValue []byte
	CreatedAt  time.Time
	CreatedBy  string
	RetiredAt  time.Time
}

KEKRecord is one key-encryption key as stored. It never contains key material: `Salt` plus the passphrase derives the key, and the salt alone is useless.

func (KEKRecord) Retired

func (k KEKRecord) Retired() bool

Retired reports whether the KEK has been shredded.

type KeyInfo

type KeyInfo struct {
	ID        string    `json:"id"`
	State     string    `json:"state"`
	Primary   bool      `json:"primary"`
	Openable  bool      `json:"openable"`
	CreatedAt time.Time `json:"created_at"`
	CreatedBy string    `json:"created_by,omitempty"`
	RetiredAt time.Time `json:"retired_at,omitempty"`
}

KeyInfo is a KEK as reported to an operator. It carries no key material and is safe to render in a CLI table or an API response.

type KeyStore

type KeyStore interface {
	// PutKEK inserts or replaces a KEK record.
	PutKEK(k KEKRecord) error
	// ListKEKs returns every KEK, including retired ones — retired rows are
	// kept so `cloop hub key list` can still explain what a row referencing
	// a dead key is referencing.
	ListKEKs() ([]KEKRecord, error)
	// PrimaryKEK returns the current primary, if there is one. It exists
	// separately from ListKEKs because it is on the seal path: every seal
	// re-reads it, so it must be a single indexed lookup rather than a scan.
	PrimaryKEK() (KEKRecord, bool, error)
	// PromoteKEK makes id the sole primary, demoting any incumbent, in one
	// transaction. Atomicity matters: two hubs promoting concurrently must
	// not both succeed and leave rows sealed under a key neither considers
	// current.
	PromoteKEK(id string, at time.Time) error
	// RetireKEK marks a KEK retired and destroys its salt. It must refuse
	// while any sealed row still references the key.
	RetireKEK(id string, at time.Time) error
}

KeyStore is the persistence a Keyring needs on top of the broker's Store.

It is separate from Store on purpose. Store has several in-memory implementations in tests whose value is that they are twenty lines long; folding key management into it would force every one of them to grow a KEK registry to keep compiling, for no gain. A store that implements both gets envelope encryption and rotation; a store that implements only Store keeps the single-key behaviour, which is exactly right for a test double.

type KeyUsage

type KeyUsage struct {
	KeyID string         `json:"key_id"`
	Total int            `json:"total"`
	BySet map[string]int `json:"by_set"`
}

KeyUsage is how many rows of one set are sealed under one key.

type Keyring

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

Keyring holds every derivable KEK and performs envelope seal/open.

Safe for concurrent use: leases and session reads both go through it from many goroutines, and a rotation mutates the primary underneath them.

func OpenKeyring

func OpenKeyring(store KeyStore, opts ...KeyringOption) (*Keyring, error)

OpenKeyring loads the KEK registry, derives every KEK the passphrase can open, and ensures a primary exists.

Derivation is deliberately eager. It costs ~60ms per KEK at 200 000 KDF rounds, once, at startup — versus paying it on the first lease of the day, inside somebody's run, per key. Eager also means an unusable passphrase is a startup failure rather than an intermittent one.

func (*Keyring) AddKey

func (k *Keyring) AddKey(actor string) (KeyInfo, error)

AddKey mints a new KEK, promotes it to primary, and returns it. Existing material stays readable under its old KEK — that overlap is what lets a rotation run while the hub serves traffic.

func (*Keyring) HasLegacy

func (k *Keyring) HasLegacy() bool

HasLegacy reports whether pre-envelope material can still be opened on this hub. False with legacy rows present means those rows are unreadable, which `cloop hub key status` reports rather than leaving to be discovered.

func (*Keyring) Keys

func (k *Keyring) Keys() []KeyInfo

Keys returns every known KEK, primary first, then newest.

func (*Keyring) OpenEnvelope

func (k *Keyring) OpenEnvelope(aad string, env Envelope) ([]byte, error)

OpenEnvelope unwraps the DEK and decrypts the payload.

The error cases are deliberately distinguishable, because they call for completely different operator responses: a retired key means the material is gone and the credential must be re-minted; an underivable key means the passphrase is wrong and the material is probably fine; a tag failure means tampering or corruption. Collapsing all three into "decryption failed" turns a five-minute diagnosis into an afternoon.

func (*Keyring) PrimaryID

func (k *Keyring) PrimaryID() string

PrimaryID returns the key ID new material is sealed under.

func (*Keyring) Reload

func (k *Keyring) Reload() error

Reload re-reads the KEK registry, adopting keys another process minted and dropping ones it retired.

It never mints and never demotes to nothing: a registry that has become unreadable leaves the in-memory state exactly as it was, because a hub that is currently serving reads is better off with a stale key set than with an empty one.

func (*Keyring) RetireKey

func (k *Keyring) RetireKey(id string) error

RetireKey destroys a KEK's salt, making it permanently underivable.

The store refuses while any row still references the key, so this is safe to call optimistically; the refusal is the second step the design requires, and it is enforced in SQL rather than by a check here that a concurrent write could race past.

func (*Keyring) Rewrap

func (k *Keyring) Rewrap(aad string, env Envelope) (Envelope, error)

Rewrap moves an envelope onto the primary KEK without decrypting its payload.

This is the operation rotation is made of, and the reason it is cheap: the payload ciphertext is returned byte-identical, so a rotation over a hundred megabytes of kubeconfigs still only encrypts a hundred DEKs. It also means a rotation never materialises a credential, so a rotation running on a serving hub does not widen the plaintext exposure window at all.

func (*Keyring) SealFor

func (k *Keyring) SealFor(aad string, plaintext []byte) (Envelope, error)

SealFor seals plaintext under a fresh DEK wrapped by the primary KEK.

aad identifies the row this envelope belongs to — build it with AADFor so it carries the sealed set as well as the row id. The identical value must be supplied to OpenEnvelope; a mismatch is an authentication failure, which is how a row-swap attack surfaces.

type KeyringOption

type KeyringOption func(*keyringConfig)

KeyringOption configures a Keyring.

func WithKeyringActor

func WithKeyringActor(a string) KeyringOption

WithKeyringActor records who caused a KEK to be created.

func WithKeyringClock

func WithKeyringClock(fn func() time.Time) KeyringOption

WithKeyringClock overrides the time source.

func WithKeyringPassphrase

func WithKeyringPassphrase(p string) KeyringOption

WithKeyringPassphrase overrides CLOOP_SECRET_KEY. Tests use it; so does any caller that sources the passphrase from somewhere other than the process environment.

func WithoutKeyCreation

func WithoutKeyCreation() KeyringOption

WithoutKeyCreation opens the keyring read-only with respect to the registry: if no primary KEK exists, OpenKeyring fails rather than minting one.

Read-only diagnostics (`cloop hub key list` against someone else's hub, a health check) should not have the side effect of creating key material, and a diagnostic that silently mints a KEK would report a healthy registry it had just fabricated.

type Kind

type Kind string

Kind classifies what a secret *is*, which determines both the constraints that apply to it and how it is minimized and delivered.

const (
	// KindGitHubPAT is a GitHub personal access token. Constrained by a
	// repository allowlist and a permission set.
	KindGitHubPAT Kind = "github_pat"
	// KindGitHubApp is a GitHub App installation credential (JSON with
	// app_id/installation_id/private_key). Constrained like a PAT.
	KindGitHubApp Kind = "github_app"
	// KindKubeconfig is a kubeconfig YAML document. Constrained by allowed
	// contexts and namespaces, and rewritten to match before delivery.
	KindKubeconfig Kind = "kubeconfig"
	// KindRegistry is a container-registry credential (docker config JSON,
	// or "user:password"). Constrained by an allowed registry list.
	KindRegistry Kind = "registry"
	// KindEnv is an opaque environment variable or set of them. Constrained
	// by an allowed key list.
	KindEnv Kind = "env"
	// KindEgressProxy is an outbound proxy endpoint, possibly with embedded
	// credentials. Constrained by an allowed-host list.
	KindEgressProxy Kind = "egress_proxy"
	// KindLocalRepo is a directory on the hub host that holds git
	// repositories. Constrained by a repository allowlist, and delivered as
	// a bind mount rather than as bytes.
	//
	// It is the odd one out here and deliberately so. The payload is a path,
	// not a credential, and the thing being granted is reach into the control
	// plane's own filesystem — which is exactly why it belongs in this
	// package rather than beside SpecMount. A path that a sandbox may read is
	// an authority, and every property this broker already provides for
	// authorities is one it needs: a named subject so the grant reaches one
	// project and not the fleet, a TTL so it lapses, an audit row recording
	// who opened it, and revocation that lands within a lease period.
	//
	// The alternative — a host path in .cloop/sandbox.yaml — is the reason
	// SpecMount sources are workspace-relative: that file is repo-committed,
	// so it is whatever a pull request says it is. This kind is the same
	// capability with the trust inverted. A human with secret.grant names the
	// path, out of band, and the project receives it.
	KindLocalRepo Kind = "local_repo"
)

func Kinds

func Kinds() []Kind

Kinds returns every valid Kind, sorted, for CLI help and validation messages.

func ParseKind

func ParseKind(s string) (Kind, error)

ParseKind validates and normalises a user-supplied kind string.

func (Kind) Valid

func (k Kind) Valid() bool

Valid reports whether k is a known kind.

type Lease

type Lease struct {
	ID         string    `json:"id"`
	ExecutorID string    `json:"executor_id,omitempty"`
	ProjectID  string    `json:"project_id,omitempty"`
	IssuedAt   time.Time `json:"issued_at"`
	ExpiresAt  time.Time `json:"expires_at"`
	// Materials carries the credentials. json:"-" on the sensitive fields
	// of Material keeps a marshalled Lease audit-safe.
	Materials []Material `json:"materials"`
}

Lease is a time-boxed bundle of materials issued to one requester.

TTL is the point of the type. A grant may last 24 hours, but the credentials an executor holds should not: a lease expires in minutes and is renewed while the work continues, so a compromised executor's window is bounded by the lease rather than by the grant.

func (*Lease) Deliver

func (l *Lease) Deliver(dir string) (*Delivery, error)

Deliver renders the lease for a workload that will find its credentials at dir on a filesystem this process does not own, without writing anything.

The Delivery holds the plaintext in memory for the driver to place — staged into a container's own tmpfs, projected as a Kubernetes Secret, or sent to an edge agent that writes it through its confined vault path. That is the whole difference from Materialize: the control plane never has a file to leak for a credential it is only relaying.

func (*Lease) Empty

func (l *Lease) Empty() bool

Empty reports whether the lease carries no credentials. An empty lease is a legitimate outcome — a project with no grants gets one — and callers should treat it as "run with no secrets", not as an error.

func (*Lease) Expired

func (l *Lease) Expired(now time.Time) bool

Expired reports whether the lease is past its TTL at now.

func (*Lease) Kinds

func (l *Lease) Kinds() []Kind

Kinds returns the distinct credential kinds the lease carries, sorted.

Deduplicated because a lease holding two GitHub PATs is one lease that involves PATs, not two: leaving the duplicate in would make the lease event counters a function of how many grants happened to match rather than of how many leases were issued.

func (*Lease) Materialize

func (l *Lease) Materialize(baseDir string) (*Mount, error)

Materialize writes the lease's credentials into a private directory and returns the Mount describing it. baseDir may be empty to use a tmpfs.

Call it only for a workload that will read this host's filesystem — see executor.Capabilities.SecretFilesFromHostPath. For anything isolated, use Deliver: writing plaintext here for a sandbox that cannot open it creates a credential file on the control plane that nothing ever reads.

The caller must Close the Mount when the workload exits. Failure partway through cleans up what was already written rather than leaving credential files behind.

func (*Lease) MaterializeAt

func (l *Lease) MaterializeAt(dir string) (*Mount, error)

MaterializeAt writes the lease's credentials into dir, which it creates.

dir must be absolute and its final element must carry leaseDirPrefix: the prefix is what every wipe path in this system uses to recognise a directory as lease-owned, and one created without it could never be swept. Creation is exclusive — a directory that already exists is refused rather than reused, so two leases cannot land in one directory and have the first Close take the second's files.

func (*Lease) SecretNames

func (l *Lease) SecretNames() []string

SecretNames lists the delivered secrets by name, for logging.

func (*Lease) TTL

func (l *Lease) TTL(now time.Time) time.Duration

TTL returns the remaining lifetime at now, clamped at zero.

type LeaseBinding

type LeaseBinding struct {
	// GrantID is the grant this material came from.
	GrantID string
	// SecretID and SecretName identify the stored secret, for diagnostics.
	SecretID   string
	SecretName string
	// Kind is the credential kind.
	Kind Kind
	// EnvKeys are the variables this grant contributed, by name.
	EnvKeys []string
	// Files are the absolute paths written for this grant.
	Files []string
	// Dir is the lease directory holding Files.
	Dir string
	// Mounts are the host paths this grant opened, recorded for the audit
	// trail rather than for revocation.
	//
	// The distinction matters and is a real limitation: a revoked lease's
	// files are wiped and its variables scrubbed, but a bind already in a
	// running sandbox's mount namespace cannot be taken back from outside it.
	// A local_repo grant therefore lapses when the workload exits, not when
	// the grant is revoked. Narrowing that window means stopping the workload.
	Mounts []RepoMount
}

LeaseBinding attributes part of a materialised lease to the grant that produced it: which environment variables it set, and which files it wrote.

It carries names and paths, never values. The point is revocation — an executor holding GITHUB_TOKEN cannot otherwise tell that the variable came from a grant an operator has just withdrawn, so "revoke this lease" could only be honoured by killing the whole workload. With the attribution, a driver scrubs exactly what the lease delivered and leaves the rest alone.

See pkg/executor.SecretBinding, the driver-facing shape this maps onto, and pkg/executor/remote/revoke.go for what a driver does with it.

type LegacyImportResult

type LegacyImportResult struct {
	// Imported names the secrets newly minted into the broker.
	Imported []string
	// Skipped names entries already present (the import is idempotent).
	Skipped []string
}

LegacyImportResult reports what a legacy import did.

func ImportLegacySecrets

func ImportLegacySecrets(ctx context.Context, b *Broker, workDir, actor string) (LegacyImportResult, error)

ImportLegacySecrets copies every entry from the flat pkg/secret store into the broker as an unscoped `env` secret with a matching wildcard grant.

"Unscoped" is the whole point and is deliberate. The entries being imported were, until this moment, delivered to every workload with no scoping at all; narrowing them during a migration would break running projects at a moment nobody chose, and would do it silently — the workload would simply stop seeing a variable it depends on. So the import preserves existing reach exactly, with SubjectAny and no expiry, and leaves tightening to an operator who can then do it one grant at a time and watch what breaks. Every imported grant is audited, so "which of my secrets are still wide open" is a query rather than an archaeology exercise.

It is idempotent: an entry whose name already exists in the broker is skipped, so this can run on every broker construction.

A missing or unreadable legacy store is not an error — most installs will not have one.

type Material

type Material struct {
	GrantID     string      `json:"grant_id"`
	SecretID    string      `json:"secret_id"`
	SecretName  string      `json:"secret_name"`
	Kind        Kind        `json:"kind"`
	Constraints Constraints `json:"constraints"`
	// Env are variables to inject, already narrowed. Values are
	// credentials; this field is json:"-" so a Material cannot be
	// serialised into an API response by accident.
	Env map[string]string `json:"-"`
	// Files are written into the lease's tmpfs directory.
	Files []File `json:"-"`
	// Mounts are host paths the grant opens to the workload. Unlike Files
	// they are not written anywhere: the path already exists and the grant
	// is permission to reach it, so materialisation validates it and the
	// driver binds it.
	//
	// This one is not json:"-", because a mount carries no secret — a source
	// path and a read-only flag are exactly what an operator inspecting a
	// lease needs to see, and hiding them would make the one material kind
	// with no credential in it the hardest to audit.
	Mounts []RepoMount `json:"mounts,omitempty"`
	// Summary is the audit-safe description of what was delivered
	// (surviving kubeconfig contexts, allowed registries, env key names).
	Summary string `json:"summary,omitempty"`
}

Material is one grant's credentials, already minimized against that grant's constraints and ready to hand to an executor.

The plaintext lives here and only here. A Material is short-lived by construction: it exists between Lease and Materialize, and the Mount that results is wiped when the workload exits.

func (Material) GitHubToken

func (m Material) GitHubToken() (string, bool)

GitHubToken returns the raw token a GitHub material carries, and whether it carries one at all.

It exists because workspace provisioning (pkg/executor/gitcreds) needs the token as an HTTP basic password, and the two paths that already deliver it — the credential-helper script and the bare GITHUB_TOKEN env var — are both wrong for that use. The helper needs a shell and a materialised lease directory, which is exactly the disk write a sandbox fetch must avoid; and GITHUB_TOKEN is only exported for an unrestricted "*" grant, so relying on it would make narrow grants silently unable to clone.

Reading the token out of Files rather than re-deriving it keeps one source of truth: whatever githubMaterial decided to deliver is what a caller sees. The returned string is the caller's to handle carefully — it is a credential, and nothing here can stop it being logged.

type MintRequest

type MintRequest struct {
	Name     string
	Kind     Kind
	Payload  []byte
	Metadata map[string]string
	Actor    string
}

MintRequest describes a secret to store.

type Mount

type Mount struct {
	// Dir is the lease directory. Mode 0700, on a tmpfs where one is
	// available.
	Dir string
	// contains filtered or unexported fields
}

Mount is a materialised lease on a filesystem: a private directory holding the credential files, plus the environment to hand the workload.

Close wipes it. The zero value is not usable; get one from Lease.Materialize.

func (*Mount) Bindings

func (m *Mount) Bindings() []LeaseBinding

Bindings returns the per-grant attribution for this mount: which variables and files each grant contributed. Names and paths only — no values.

A driver uses it to build executor.SecretBinding, which is what makes a mid-run revocation targetable at one credential instead of at the whole workload.

func (*Mount) Close

func (m *Mount) Close() error

Close wipes and removes the mount. It is idempotent and safe to call from a defer alongside an explicit call.

Each file is overwritten with zeros before removal. On a tmpfs that is belt-and-braces, since the pages are freed anyway; on the os.TempDir() fallback it is the only thing standing between an unlinked credential and whoever reads the underlying blocks next. It is not a guarantee against a copy-on-write or log-structured filesystem, which is why tmpfs is preferred rather than merely convenient.

func (*Mount) Env

func (m *Mount) Env() []string

Env returns the environment additions in "K=V" form, sorted, ready to append to an executor.Spec's Env.

func (*Mount) Mounts

func (m *Mount) Mounts() []RepoMount

Mounts returns the host repositories this lease opens, in the order the materials were processed. The caller binds them into the sandbox; there is nothing on disk here to clean up, so Close does not touch them.

type Option

type Option func(*Broker)

Option configures a Broker.

func WithAuditor

func WithAuditor(a Auditor) Option

WithAuditor sets the audit sink. Without one, events are dropped — which is acceptable for tests and for read-only CLI paths, and never for the control plane.

func WithCipher

func WithCipher(c *Cipher) Option

WithCipher supplies a pre-built Cipher, bypassing CLOOP_SECRET_KEY.

A Cipher has no key registry, so a broker built this way seals in the legacy single-key shape and cannot rotate. That is right for a test double and wrong for a hub; prefer WithKeyring where rotation matters.

func WithClock

func WithClock(fn func() time.Time) Option

WithClock overrides the time source. Tests use it to exercise TTL expiry without sleeping.

func WithKeyring

func WithKeyring(kr *Keyring) Option

WithKeyring supplies a pre-opened Keyring, so a caller that already built one (the hub, which shares it with the session store) does not pay the KDF cost a second time.

func WithMaxLeaseTTL

func WithMaxLeaseTTL(d time.Duration) Option

WithMaxLeaseTTL overrides DefaultMaxLeaseTTL. Values above the grant's own remaining lifetime are still clamped to it.

type RepoMount

type RepoMount struct {
	// Name is the repository's directory name under the granted root, and
	// the handle a workload refers to it by.
	Name string `json:"name"`
	// Source is the absolute host path. Always inside the granted root:
	// selectRepos resolves symlinks before accepting one, so a symlink
	// planted under the root cannot redirect the bind out of it.
	Source string `json:"source"`
	// Target is the absolute path the repository appears at in the sandbox.
	Target string `json:"target"`
	// ReadOnly mirrors the grant's Writable constraint, inverted, because
	// read-only is the default and a zero value should mean the safe thing.
	ReadOnly bool `json:"read_only,omitempty"`
}

RepoMount is one local git repository a grant opens to a workload.

It names paths on two different machines' terms: Source is on the host the executor runs on, Target is inside the sandbox. They are equal only for a driver that shares the host filesystem, which is why the distinction is in the type rather than left to the driver to remember.

type Requester

type Requester struct {
	ExecutorID string
	ProjectID  string
	Labels     map[string]string
}

Requester is the identity a lease is issued to: one executor running work for one project. Labels are the executor's own labels, used by SubjectLabel grants.

type RotateOptions

type RotateOptions struct {
	// NewKey mints a fresh KEK and promotes it before rewrapping. This is
	// the normal case: rotating onto the *current* primary is an upgrade of
	// stragglers, not a rotation.
	NewKey bool
	// Actor is recorded on the new key and the rotation record.
	Actor string
	// DryRun reports what would be rewrapped without writing anything.
	DryRun bool
}

RotateOptions configures one run.

type RotationRecord

type RotationRecord struct {
	ID         string    `json:"id"`
	ToKeyID    string    `json:"to_key_id"`
	State      string    `json:"state"`
	StartedAt  time.Time `json:"started_at"`
	UpdatedAt  time.Time `json:"updated_at"`
	FinishedAt time.Time `json:"finished_at,omitempty"`
	StartedBy  string    `json:"started_by,omitempty"`
	Total      int       `json:"total"`
	Rewrapped  int       `json:"rewrapped"`
	Skipped    int       `json:"skipped"`
	Failed     int       `json:"failed"`
	LastError  string    `json:"last_error,omitempty"`
}

RotationRecord is the operator-facing history of a rotation run.

type RotationReport

type RotationReport struct {
	RotationID  string      `json:"rotation_id,omitempty"`
	TargetKeyID string      `json:"target_key_id"`
	Sets        []SetReport `json:"sets"`
	Rewrapped   int         `json:"rewrapped"`
	Skipped     int         `json:"skipped"`
	Failed      int         `json:"failed"`
	Complete    bool        `json:"complete"`
	DryRun      bool        `json:"dry_run,omitempty"`
}

RotationReport is the outcome of one Rotate call.

type RotationStatus

type RotationStatus struct {
	PrimaryKeyID string           `json:"primary_key_id"`
	Keys         []KeyInfo        `json:"keys"`
	Usage        []KeyUsage       `json:"usage"`
	Legacy       int              `json:"legacy_rows"`
	LegacyOpen   bool             `json:"legacy_openable"`
	Unrotated    int              `json:"unrotated_rows"`
	Rotations    []RotationRecord `json:"rotations,omitempty"`
}

RotationStatus is the whole picture: which keys exist, what each is sealing, and how the last rotation went.

type RotationStore

type RotationStore interface {
	PutRotation(r RotationRecord) error
	ListRotations(limit int) ([]RotationRecord, error)
}

RotationStore persists rotation history. Optional: a rotator without one still rotates, it just cannot show progress after the process exits.

type Rotator

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

Rotator rewraps every sealed row onto the keyring's primary KEK.

func NewRotator

func NewRotator(kr *Keyring, sets ...SealedSet) (*Rotator, error)

NewRotator builds a rotator over a keyring and one or more sealed sets.

func (*Rotator) RetireKey

func (r *Rotator) RetireKey(id string) error

RetireKey retires a KEK after confirming nothing references it.

The check here is advisory and the store's is authoritative — a row could be written between the two — but running it first turns the common case into a message naming the sets and counts that are blocking, instead of a bare constraint violation from SQLite.

func (*Rotator) Rotate

func (r *Rotator) Rotate(ctx context.Context, opts RotateOptions) (RotationReport, error)

Rotate rewraps every row onto the primary KEK.

Safe to call repeatedly, concurrently with hub traffic, and after an interruption — see the file comment. The returned report is complete only when every set drained; otherwise ErrRotationFailed is returned alongside a report that says precisely how far it got, and re-running continues.

func (*Rotator) Status

func (r *Rotator) Status(historyLimit int) (RotationStatus, error)

Status assembles the report behind `cloop hub key status`.

func (*Rotator) WithBatch

func (r *Rotator) WithBatch(n int) *Rotator

WithBatch overrides the batch size.

func (*Rotator) WithHistory

func (r *Rotator) WithHistory(h RotationStore) *Rotator

WithHistory attaches a store for rotation records.

func (*Rotator) WithProgress

func (r *Rotator) WithProgress(fn func(RotationRecord)) *Rotator

WithProgress installs a callback invoked after each batch, for a CLI progress line.

type SealedRow

type SealedRow struct {
	ID  string
	AAD string
	Env Envelope
}

SealedRow is one piece of sealed material as seen by the rotator.

It carries no plaintext and the rotator never produces any: rewrapping operates on the DEK, not the payload. AAD is the row's associated data — its ID — which must round-trip exactly or the rewrap fails authentication.

type SealedSet

type SealedSet interface {
	// SealedSetName identifies the set in reports ("secrets", "sessions").
	SealedSetName() string
	// CountSealedByKey returns row counts keyed by key ID, including
	// LegacyKeyID for pre-envelope rows.
	CountSealedByKey() (map[string]int, error)
	// ListSealedNotUnder returns up to limit rows whose key ID differs from
	// keyID. Order is unspecified; the rotator does not depend on it.
	ListSealedNotUnder(keyID string, limit int) ([]SealedRow, error)
	// ReplaceSealed swaps a row's envelope if and only if the stored one
	// still matches expect exactly. It reports whether the swap happened;
	// false is a normal outcome (a concurrent writer got there first), not
	// an error.
	ReplaceSealed(id string, expect, next Envelope) (bool, error)
}

SealedSet is one population of rows that participates in rotation.

Implemented by pkg/secretstore (brokered secrets) and pkg/sessionstore (session refresh tokens). Anything else that seals long-lived material under the hub key implements this and is rotated for free — which is the point of the interface: the alternative is a rotation command that knows the names of the tables, and therefore silently misses the next one.

type Secret

type Secret struct {
	ID   string `json:"id"`
	Kind Kind   `json:"kind"`
	// Name is a human-facing unique handle ("prod-deploy-pat"). Grants are
	// created against it from the CLI.
	Name string `json:"name"`
	// Sealed is the AES-256-GCM ciphertext of the payload, encrypted under
	// this secret's own data key (see WrappedDEK). Never logged.
	Sealed []byte `json:"-"`
	// KeyID names the KEK that WrappedDEK is sealed under. LegacyKeyID means
	// the pre-envelope construction: WrappedDEK is empty and Sealed is
	// encrypted directly under the passphrase-derived key (Task 20181).
	//
	// It is exported because storage must persist it, and json:"-" because a
	// key ID in an API response tells a caller nothing they can act on while
	// telling an attacker which rows a stolen key still opens.
	KeyID string `json:"-"`
	// WrappedDEK is this secret's data key, sealed under KeyID. Rotation
	// rewrites this field and leaves Sealed untouched.
	WrappedDEK []byte `json:"-"`
	// Metadata is non-sensitive descriptive data (owner, rotation date).
	// It is included in audit payloads, so callers must not put credential
	// material here.
	Metadata  map[string]string `json:"metadata,omitempty"`
	CreatedAt time.Time         `json:"created_at"`
	CreatedBy string            `json:"created_by,omitempty"`
}

Secret is a stored credential. The payload is sealed (AES-256-GCM) at all times outside the broker: there is deliberately no plaintext field on this struct, so a Secret that escapes into a log, an API response, or the storage layer cannot carry the credential with it.

func (Secret) Envelope

func (s Secret) Envelope() Envelope

Envelope returns the secret's sealed material in the form the keyring understands.

func (Secret) Validate

func (s Secret) Validate() error

Validate checks a Secret's structural invariants before it is stored.

func (Secret) WithEnvelope

func (s Secret) WithEnvelope(env Envelope) Secret

WithEnvelope returns a copy of the secret carrying env's sealed material.

type SetReport

type SetReport struct {
	Name      string   `json:"name"`
	Rewrapped int      `json:"rewrapped"`
	Skipped   int      `json:"skipped"`
	Failed    int      `json:"failed"`
	Errors    []string `json:"errors,omitempty"`
}

SetReport is per-set rotation detail.

type Store

type Store interface {
	// PutSecret inserts or replaces a secret by ID.
	PutSecret(s Secret) error
	// GetSecret returns the secret with the given ID, or a wrapped
	// ErrSecretNotFound.
	GetSecret(id string) (Secret, error)
	// ListSecrets returns every stored secret.
	ListSecrets() ([]Secret, error)
	// DeleteSecret removes a secret. Removing a secret that does not exist
	// returns a wrapped ErrSecretNotFound.
	DeleteSecret(id string) error

	// PutGrant inserts or replaces a grant by ID.
	PutGrant(g Grant) error
	// GetGrant returns the grant with the given ID, or a wrapped
	// ErrGrantNotFound.
	GetGrant(id string) (Grant, error)
	// ListGrants returns every stored grant, including expired and revoked
	// ones — the broker filters, so that callers listing for an audit UI
	// can still see history.
	ListGrants() ([]Grant, error)
	// RevokeGrant stamps a grant revoked at the given time. Revoking an
	// already-revoked grant is a no-op, not an error: revocation is
	// idempotent by design, because a caller racing to revoke twice must
	// not be told the second attempt failed.
	RevokeGrant(id string, at time.Time) error

	// Meta reads a broker-scoped metadata value.
	Meta(key string) (value string, ok bool, err error)
	// SetMeta writes a broker-scoped metadata value.
	SetMeta(key, value string) error
}

Store is the persistence contract the broker needs.

It is an interface, and it lives here rather than in pkg/statedb, for the same reason pkg/executor/remote defines its own Store: the broker's policy logic is the part worth testing exhaustively, and it should not need a SQLite file to test. pkg/secretstore is the production implementation.

The payload crosses this boundary sealed and only sealed. Store implementations therefore cannot leak plaintext credentials even if they log every argument they receive — which is the point, since a storage layer is exactly the kind of code that grows debug logging.

type Subject

type Subject struct {
	Type SubjectType `json:"type"`
	// Value is the project path or executor ID for those types, "*" for a
	// wildcard within the type, and unused for label/any.
	Value string `json:"value,omitempty"`
	// Labels is the selector for SubjectLabel: every pair must be present
	// on the requester for the grant to match.
	Labels map[string]string `json:"labels,omitempty"`
}

Subject is the "who" of a grant.

func ParseSubject

func ParseSubject(spec string) (Subject, error)

ParseSubject parses the CLI's "--to" syntax:

project:/srv/app     one project, by path
project:*            every project
executor:edge-01     one executor
label:region=eu,gpu=true   every executor carrying both labels
any / *              every requester (used by the legacy import)

An unrecognised or empty spec is an error rather than a permissive default: a typo in a grant target must not silently widen it.

func (Subject) Matches

func (s Subject) Matches(r Requester) bool

Matches reports whether this subject selects r.

A wildcard "*" is honoured within project/executor types, but only when written explicitly — an empty Value never matches, so a half-constructed Subject fails closed instead of matching everything.

func (Subject) String

func (s Subject) String() string

String renders a Subject back into the CLI's "--to" syntax.

func (Subject) Validate

func (s Subject) Validate() error

Validate checks that a Subject is well-formed.

type SubjectType

type SubjectType string

SubjectType is the dimension a Grant's subject selects on.

const (
	// SubjectProject scopes a grant to one project (or "*" for all).
	SubjectProject SubjectType = "project"
	// SubjectExecutor scopes a grant to one executor ID (or "*").
	SubjectExecutor SubjectType = "executor"
	// SubjectLabel scopes a grant to executors carrying every label in the
	// selector — the way a fleet of edge devices is addressed without
	// enumerating device IDs.
	SubjectLabel SubjectType = "label"
	// SubjectAny matches every requester. Reserved for the legacy import
	// path, where narrowing an existing flat secret store would silently
	// break running projects.
	SubjectAny SubjectType = "any"
)

Jump to

Keyboard shortcuts

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