secrets

package
v0.5.4 Latest Latest
Warning

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

Go to latest
Published: Jul 23, 2026 License: AGPL-3.0 Imports: 40 Imported by: 0

README

pkg/secrets — Root Key Providers

This package implements the platform's at-rest credential encryption. The root key (KEK) is the root of trust for every credential stored at rest: admin/org LLM API keys, org SSO client secrets, and (via the Redis DEK cache) every user DEK while it lives in Redis.

The RootKeyProvider interface (Encrypt/Decrypt) is the single abstraction over how the root key is held. Two local implementations (Static, Sealed) and two cloud-KMS implementations (AWSKMS, GPCKMS) ship today. A self-hosted external provider (Vault / OpenBao Transit) remains a possible future community contribution — see design/stories/epic-57-rce-resistance-hardening/README.md §"Out of scope" for why cloud KMS was chosen over Transit.

Provider implementations

Provider rootKeyProvider value Where the key material lives Use when
StaticKeyProvider "" (Helm default) or "static" In a Kubernetes Secret, delivered as a read-only file mount (Epic 50 US-50.1 default) or legacy env var, held in API-process memory for the pod's lifetime Development only. Single key, no rotation. The file mount removes /proc/1/environ exposure; the legacy env path remains as a deprecated opt-in (masterSecret.deliveryMethod=env). Emits a startup warning.
SealedKeyProvider "sealed" In a sealed file on disk; the root key is wrapped by an Argon2id KEK derived from an operator-supplied passphrase Production (self-hosted). The root key is not present in env vars; an attacker who reads the sealed file but not the passphrase cannot recover it.
AWSKMSProvider "aws-kms" (Epic 57 US-57.1) In AWS KMS — the key material never leaves AWS. Every Encrypt/Decrypt is a network round-trip to the KMS API. Production (AWS). Converts API-pod RCE from permanent KEK exfiltration to ephemeral compromise bounded by the RCE window. File-mounted static AWS credentials (not IRSA — narrower trust surface per US-50.1's pattern).
GPCKMSProvider "gcp-kms" (Epic 57 US-57.3) In Google Cloud KMS — the key material never leaves Google's HSM. Same threat-model as AWS KMS. Production (GCP). File-mounted service-account JSON (not WIF — narrower trust surface). CRC32C integrity verification on every request and response.
CompositeProvider (internal, wraps any of the above) Dispatches Decrypt by ciphertext prefix (lkms:v1:, aws-kms:v1:, gcp-kms:v1:). Primary for Encrypt; primary + fallbacks for Decrypt. Enables zero-downtime migration between providers. The composite's static fallback decrypts legacy rows during migration.

Selection is read in api/internal/app/secrets_adapters.go (newRootKeyProvider) from cfg.Security.RootKeyProvider (env: LLMSAFESPACES_SECURITY_ROOTKEYPROVIDER).

Cloud KMS availability (D9)

Under KMS, every Decrypt call is a network round-trip. Sustained KMS unavailability (regional outage, network partition) causes all KEK-dependent decrypts to fail simultaneously. This is an inherent trade-off of cloud KMS. Multi-region KMS key replicas are recommended for HA deployments. The CompositeProvider's static fallback does NOT mitigate KMS-primary unavailability — it only runs on ciphertext prefix mismatch (legacy rows).

Threat model

"Mitigated?" assumes the listed attacker is the only vector in play. Defense in depth requires assuming they are not.

Attacker capability Static Sealed AWS KMS GCP KMS
Read-only filesystem access (no process memory) No — key is in a kubelet Secret (file-mounted or env) Yes — sealed file is useless without the passphrase Yes — key material is in KMS, never on disk; the file-mounted AWS credentials are an IAM identity (callable over the network), not the key itself Yes — same as AWS KMS; the file-mounted SA JSON is an IAM identity, not the key material
Node-level disk read (stolen disk, snapshot) No Yes Yes Yes
Read /proc/<api-pid>/environ from the node Partial — file-mount default (US-50.1) keeps the key out of env; legacy deliveryMethod=env still leaks it Yes — the root key is never in env vars; the passphrase is, but the sealed file alone is useless without it Yes — AWS credentials are file-mounted (US-57.1 D2); KMS key material is never in the pod Yes — SA JSON is file-mounted (US-57.3 D1); KMS key material is never in the pod
Process-level access to the API pod (RCE) No No — the unsealed root key lives in process memory; an attacker calls Decrypt() exactly as legitimate code does Partial — the key never leaves KMS, but decrypt is still callable while the RCE is live; the value is exfiltration-limitation + CloudTrail audit, not prevention Partial — same as AWS KMS; value is exfiltration-limitation + Cloud Audit Logs
Full memory dump of the API pod No No Partial — no key material in memory; decrypt still callable Partial — same as AWS KMS
Ciphertext exfiltration (DB backup leak) No — without rotation the leak is permanent No (same — until rotation exists) Best — the backup is useless without re-acquiring live KMS access; CloudTrail records every decrypt attempt Best — same as AWS KMS via Cloud Audit Logs

Key takeaway: the dominant threat is RCE in the API pod. No local provider fully mitigates that — once an attacker runs code in the pod they can decrypt as the application does. The sealed provider's real value is preventing offline recovery after disk/env-var exfiltration, and removing the root key from /proc/1/environ. A cloud KMS provider (AWS or GCP) adds the property the local providers cannot: the key material never leaves the cloud HSM, so an attacker who evicts the RCE loses the decrypt capability entirely and a separately-stolen DB backup is useless. The improvement is exfiltration limitation + independent cloud-side audit, not prevention of in-process abuse while the RCE is live.

Choosing a provider

  • Local development / CI: static (the default). The startup warning is expected; suppress it with LLMSAFESPACES_SECURITY_SKIPMASTERKEYWARNING=true only for environments that genuinely cannot surface logs.
  • Production (self-hosted): sealed. Generate the sealed file with cmd/seal-key (the root key is never printed unless -print-key is passed) and mount the sealed file plus a passphrase Secret into the API pod.
  • Production (AWS): aws-kms. Set security.rootKeyProvider: aws-kms plus three per-purpose KMS key ARNs (providerCredentials, orgCredentials, masterKek) and a credentials-file Secret. Migrate an existing deployment with cmd/migrate-kek (zero-downtime) — see helm/KEK-MIGRATION.md.
  • Production (GCP): gcp-kms. Same shape as AWS with GCP key resource names and a service-account JSON Secret.

Sealed-key file format

cmd/seal-key writes the root key sealed under an Argon2id KEK derived from the passphrase:

  • V1 (current, US-50.11): magic "LSKP-S"salt(32)nonce(12)ciphertext. The KEK is Argon2id over the passphrase, with the HKDF info string llmsafespaces-sealed-root mixed into the salt for domain separation (see DeriveSealedKEK in crypto.go).
  • V0 (legacy): salt(32)nonce(12)ciphertext, KEK = Argon2id with no info string. NewSealedKeyProvider still reads V0 files, so deployments upgraded in place keep working.

The magic prefix is the one place a ciphertext-format version is justified: sealed-key files are standalone artifacts detached from any database row's key_version column, so the version must travel with the file.

Documentation

Index

Constants

View Source
const (
	KDFVersionHKDF     = 0
	KDFVersionArgon2id = 1
	KDFCurrentVersion  = KDFVersionArgon2id
)
View Source
const DefaultJWTSessionJanitorInterval = 60 * time.Second

DefaultJWTSessionJanitorInterval is the period between expiry-pruning passes on the jwt_sessions table. 60s is small relative to the JWT lifetimes the table holds (24h default, 30d remember-me), so a row pruned a minute late costs nothing observable. The DELETE WHERE expires_at < NOW() query is O(log N) thanks to idx_jwt_sessions_expires_at.

View Source
const JWTSessionKEKInfo = "llmsafespaces-jwt-session-dek-kek"

JWTSessionKEKInfo is the HKDF `info` constant used to derive the KEK that wraps the durable per-JWT DEK. Pinned here so the rehydrate path and the login durable-write path produce byte-identical KEKs.

View Source
const SlugRegex = `^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`

SlugRegex is the canonical slug-validation regex. Must be byte-identical to the DB CHECK regex in 000001_initial_schema.up.sql:

CHECK (slug ~ '^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$')

Semantics: 1-64 chars, lowercase alphanumeric + hyphens, must start AND end with alphanumeric (no leading/trailing hyphen).

Variables

View Source
var (
	ErrDecryptionFailed  = errors.New("decryption failed: ciphertext tampered or wrong key")
	ErrInvalidCiphertext = errors.New("ciphertext too short")
	ErrInvalidSaltLength = errors.New("salt must be 32 bytes")
)
View Source
var (
	// ErrSecretNotFound is returned when a secret does not exist or
	// is not owned by the requesting user. Both cases are conflated
	// to avoid leaking workspace existence cross-user.
	ErrSecretNotFound = &pkgerrors.StatusError{
		Status:  http.StatusNotFound,
		Code:    "secret_not_found",
		Message: "secret not found",
	}

	// ErrDuplicateSecret is returned when CreateSecret would violate
	// the (user_id, name) uniqueness constraint.
	ErrDuplicateSecret = &pkgerrors.StatusError{
		Status:  http.StatusConflict,
		Code:    "duplicate_secret",
		Message: "secret with this name already exists",
	}

	// ErrDEKUnavailable is returned when the per-session DEK is not
	// in the cache (typically because the JWT's jti has expired or
	// the user has not logged in since the cache was flushed).
	ErrDEKUnavailable = &pkgerrors.StatusError{
		Status:  http.StatusForbidden,
		Code:    "dek_unavailable",
		Message: "encryption key not available; re-authenticate",
	}

	// ErrInvalidSecretType is returned when a CreateSecret request
	// names a type outside ValidSecretTypes.
	ErrInvalidSecretType = &pkgerrors.StatusError{
		Status:  http.StatusBadRequest,
		Code:    "invalid_secret_type",
		Message: "invalid secret type",
	}

	// ErrInvalidMetadata is returned when the metadata blob is
	// missing a required field for the secret type, fails JSON
	// validation, or contains an adversarial mount_path.
	ErrInvalidMetadata = &pkgerrors.StatusError{
		Status:  http.StatusBadRequest,
		Code:    "invalid_metadata",
		Message: "invalid secret metadata",
	}

	// ErrInvalidPassword is returned by RevealSecret when the
	// password reconfirmation step fails. The handler maps this to
	// a uniform 403 — the same status used for missing DEK — so
	// the response shape does not differentiate between "wrong
	// password" and "session expired", reducing what an attacker
	// who has stolen a JWT can learn.
	ErrInvalidPassword = &pkgerrors.StatusError{
		Status:  http.StatusForbidden,
		Code:    "invalid_password",
		Message: "access denied",
	}

	// ErrUserKeysMissing is returned when the user_keys row for the
	// caller does not exist (e.g. legacy account that pre-dates
	// Epic 10 key initialisation, or a half-failed Register).
	ErrUserKeysMissing = &pkgerrors.StatusError{
		Status:  http.StatusPreconditionFailed,
		Code:    "user_keys_missing",
		Message: "user key material not initialized; please re-login",
	}

	// ErrInvalidLLMProvider is returned when LLMProviderData validation
	// fails (missing provider, missing API key, etc.).
	ErrInvalidLLMProvider = &pkgerrors.StatusError{
		Status:  http.StatusBadRequest,
		Code:    "invalid_llm_provider",
		Message: "invalid LLM provider data",
	}

	// ErrWorkspaceNotOwned is returned by binding operations when
	// the caller does not own the target workspace. Both
	// "workspace doesn't exist" and "workspace owned by someone
	// else" map to this single sentinel so the response shape does
	// not leak workspace existence cross-user. Handlers map to 404.
	//
	// The message says only "workspace not found" — NOT "or not
	// owned by caller" — because a future caller that logs
	// err.Error() would otherwise leak the same distinction the
	// type system was designed to hide. Callers that need to
	// classify the failure mode use errors.Is.
	ErrWorkspaceNotOwned = &pkgerrors.StatusError{
		Status:  http.StatusNotFound,
		Code:    "workspace_not_found",
		Message: "workspace not found",
	}

	// ErrCiphertextDecryptFailed is returned when the DEK was
	// successfully obtained but the stored ciphertext cannot be
	// decrypted with it (AEAD authentication failure). This is
	// distinct from ErrDEKUnavailable: the key material is present,
	// but it does not match the ciphertext.
	//
	// Most common cause: the user's DEK was rotated or the user_keys
	// row was rewritten without re-encrypting the secrets that were
	// encrypted under the old DEK. Less common: ciphertext corruption,
	// schema version skew (key_version mismatch), or storage tampering.
	//
	// The DEK itself is fine — re-authenticating will not help.
	ErrCiphertextDecryptFailed = &pkgerrors.StatusError{
		Status: http.StatusConflict,
		Code:   "ciphertext_decrypt_failed",
		Message: "this secret cannot be decrypted with your current encryption key — " +
			"the ciphertext was likely encrypted with a previous key. " +
			"Re-create the secret to recover; if you have not changed your password, " +
			"contact an administrator and reference your audit log.",
	}
)

Sentinel errors returned by the secrets package. Each carries its HTTP status code and user-facing message via StatusError, so the generic error handler (respondWithError in router.go) maps them automatically — no handler-level switch needed.

errors.Is still works for sentinel checks (pointer identity via chain traversal). errors.As can extract the *StatusError for generic typed handling.

Wrapping (`fmt.Errorf("...: %w", ErrSecretNotFound)`) is supported and recommended so the classification survives upstream formatting.

View Source
var ErrAutoBindingProtected = &pkgerrors.StatusError{
	Status:  http.StatusConflict,
	Code:    "auto_binding_protected",
	Message: "auto-binding cannot be removed via unbind; delete the credential or workspace to remove it",
}

ErrAutoBindingProtected is returned when a caller attempts to Unbind a credential that is bound via an auto-apply rule (source_type='auto'). Auto-bindings are managed by SeedWorkspaceCredentials and can only be removed by deleting the underlying credential or the workspace.

This sentinel lives in pkg/ (not api/internal/errors) because it is shared between the API server and the agentd daemon. It uses StatusError so the generic error handler maps it to HTTP 409 automatically.

View Source
var ErrNotMyCiphertext = errors.New("ciphertext prefix does not match this provider")

ErrNotMyCiphertext is returned by a RootKeyProvider's Decrypt when the ciphertext's prefix does not match the provider's format. It is distinct from ErrDecryptionFailed (crypto.go), which means "the prefix matched but the key was wrong" — a genuine decrypt failure rather than a routing signal.

Used by CompositeProvider (composite_provider.go) to dispatch Decrypt across multiple providers without false-positive error logs: a provider returning ErrNotMyCiphertext tells the composite "try the next one," while ErrDecryptionFailed tells it "this row is mine but corrupt — stop." Without this distinction, a multi-provider decrypt would log a spurious failure for every provider that didn't match the prefix.

Plain sentinel (not *StatusError) because this is an internal routing signal — it never reaches the HTTP layer.

View Source
var MetadataRequirementsBySecretType = map[SecretType][]string{
	SecretTypeAPIKey:        {},
	SecretTypeLLMProvider:   {},
	SecretTypeSSHKey:        {"key_type"},
	SecretTypeGitCredential: {},
	SecretTypeSecretFile:    {"mount_path"},
	SecretTypeEnvSecret:     {"var_name"},
}

MetadataRequirementsBySecretType is a self-documenting map of which metadata keys each secret type requires. Surfaced in error responses (Bug 7 in worklog 0085) so callers don't have to reverse-engineer the schema from 400s.

View Source
var ValidKinds = []string{
	"openai",
	"anthropic",
	"google",
	"opencode",
	"bedrock",
	"azure_openai",
	"vertex",
	"cohere",
	"mistral",
	"perplexity",
	"groq",
	"xai",
	"openrouter",
	"together",
	"openai_compatible",
}

ValidKinds is the canonical SDK-class enum. Adding a new kind requires a coordinated migration that extends the DB CHECK constraint.

Order matches the CHECK constraint declaration in the migration so the two are visually aligned during review.

ValidSecretTypes is the set of allowed secret types.

Functions

func ActiveVersionOf

func ActiveVersionOf(p RootKeyProvider) int

ActiveVersionOf returns the active key version of a provider, or 1 if the provider does not implement VersionedProvider (e.g. nil or a future external provider). This is the safe default — version 1 is the initial migration default for all tables.

func ContextWithDecryptUser

func ContextWithDecryptUser(ctx context.Context, userID string) context.Context

ContextWithDecryptUser returns a context carrying the user ID for decrypt audit attribution. Callers should pass the resulting context to the provider's Decrypt method.

func ContextWithMatchedSigningKey

func ContextWithMatchedSigningKey(ctx context.Context, matchedSigningKey []byte) context.Context

ContextWithMatchedSigningKey carries the JWT signing key that validated the caller's token, so PostgresSecretProvider.Encrypt/Decrypt can pass it through to KeyService.GetDEK for durable-DEK rehydrate (Epic 56). Pass nil for API-key / sessionless paths — KeyService.GetDEK will surface ErrDEKUnavailable when rehydrate is needed.

Note: this is the SecretProvider's own typed-context key, NOT the gin.Context value set by AuthMiddleware. Handlers extract from gin and stash here before invoking provider methods.

Current state (PR #421): no production caller sets this value because PostgresSecretProvider itself has no production caller — *SecretService (not *PostgresSecretProvider) is the live path for user-secret CRUD. The matched-key plumbing is preserved on the SecretProvider interface for symmetry with the design doc and so a future revival of the SecretProvider path inherits Epic 56 rehydrate by setting this key; without that revival it is harmless dead code, not a regression.

func ContextWithSessionID

func ContextWithSessionID(ctx context.Context, sessionID string) context.Context

ContextWithSessionID adds a session ID to the context for the SecretProvider.

func DecryptSecret

func DecryptSecret(dek, ciphertext []byte) ([]byte, error)

func DeriveKEKFromKey

func DeriveKEKFromKey(keyMaterial, salt []byte, info string) ([]byte, error)

func DeriveKEKFromPassword

func DeriveKEKFromPassword(password, salt []byte) ([]byte, error)

func DeriveKEKFromPasswordV0

func DeriveKEKFromPasswordV0(password, salt []byte, info string) ([]byte, error)

func DeriveSealedKEK

func DeriveSealedKEK(password, salt []byte, info string) ([]byte, error)

DeriveSealedKEK derives the KEK used to wrap the sealed root-key file's root key. It domain-separates via HKDF: Argon2id has no native info/context parameter, so HKDF derives a 32-byte sub-salt from the stored salt bound to info, and that sub-salt feeds Argon2id's salt input. Different info values therefore produce cryptographically independent KEKs for an identical passphrase + salt, while retaining Argon2id's memory-hardness against the (typically low-entropy) passphrase. See US-50.11.

func EncryptSecret

func EncryptSecret(dek, plaintext []byte) ([]byte, error)

func GenerateDEK

func GenerateDEK() ([]byte, error)

func GenerateRecoveryKey

func GenerateRecoveryKey() ([]byte, error)

func GenerateSalt

func GenerateSalt() ([]byte, error)

func SealRootKey

func SealRootKey(path string, passphrase, rootKey []byte) error

func UnwrapDEK

func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error)

func ValidateKind

func ValidateKind(kind string) error

ValidateKind returns nil if kind is one of the recognized SDK-class enum values, otherwise an error describing the rejection. Empty kind is rejected with a distinct message so the handler can map both cases to HTTP 400 with a clear field-specific error.

func ValidateSlug

func ValidateSlug(slug string) error

ValidateSlug returns nil if slug matches the canonical slug regex, otherwise an error describing the rejection.

The error message names the constraint (length, charset, anchors) rather than echoing the regex verbatim, which is more useful to API consumers than "regex did not match".

func WrapDEK

func WrapDEK(kek, dek []byte) ([]byte, error)

Types

type APIKeyRecord

type APIKeyRecord struct {
	ID            string
	WrappedDEK    []byte
	KekSalt       []byte
	KeyCiphertext []byte
	DecryptAccess bool
}

APIKeyRecord is the subset of API key data needed for DEK re-wrap.

type APIKeyStore

type APIKeyStore interface {
	ListAPIKeysWithDecrypt(ctx context.Context, userID string) ([]*APIKeyRecord, error)
	UpdateAPIKeyDEK(ctx context.Context, keyID string, wrappedDEK, kekSalt []byte, synced bool) error
}

APIKeyStore abstracts database operations for API key DEK re-wrap.

type AWSKMSProvider added in v0.3.0

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

AWSKMSProvider implements RootKeyProvider using AWS KMS for encrypt/decrypt operations. The key material never leaves AWS — every Encrypt and Decrypt call is a network round-trip to the KMS API. This converts an API-pod RCE from "permanent KEK exfiltration" to "ephemeral compromise bounded by the RCE window" (Epic 57 US-57.1, threat-model row 2.4).

Auth is via file-mounted static AWS credentials (D2), not IRSA — narrower trust surface per US-50.1's file-mount pattern.

One provider instance holds one KMS key ID. Per-purpose domain separation (D4) is achieved by constructing multiple instances, one per purpose, each with its own key ARN. The chart exposes per-purpose key ARN configuration.

func NewAWSKMSProvider added in v0.3.0

func NewAWSKMSProvider(client *kms.Client, keyID string) *AWSKMSProvider

NewAWSKMSProvider constructs a provider from an SDK client and KMS key ID (full ARN, e.g. "arn:aws:kms:us-east-1:123:key/abc-def"). The client must be pre-configured with credentials, region, and (optionally) a custom endpoint for testing.

func (*AWSKMSProvider) Decrypt added in v0.3.0

func (p *AWSKMSProvider) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)

Decrypt strips the aws-kms:v1: prefix and calls KMS Decrypt. A foreign prefix returns ErrNotMyCiphertext so CompositeProvider can route to the next provider. A matching prefix with a corrupt base64 body returns ErrDecryptionFailed (same semantics as the local providers).

Unlike the local providers, there is NO legacy un-prefixed fallback path. KMS ciphertexts always carry the aws-kms:v1: prefix from day one — there are no pre-US-57.1 KMS rows to be backward-compatible with.

func (*AWSKMSProvider) Encrypt added in v0.3.0

func (p *AWSKMSProvider) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)

Encrypt calls KMS Encrypt and wraps the result with aws-kms:v1: prefix.

type AdminKeyDeriver deprecated

type AdminKeyDeriver func(label string) []byte

AdminKeyDeriver derives a server-side encryption key for admin credentials. The label parameter scopes the derived key (e.g. "provider-credentials"). Returns nil when LLMSAFESPACES_MASTER_SECRET is not set.

Deprecated: US-50.2 unifies admin/org credential crypto under RootKeyProvider. New code must not use this type; it is retained for one release so callers can fall back to the legacy path if a production issue surfaces. Removed in a follow-up release. See design/stories/epic-50-master-kek-hardening/README.md.

type AsyncAuditLogger

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

AsyncAuditLogger wraps a SecretStore and logs audit entries asynchronously. The hot path (Log) never blocks: a full channel drops the entry and increments DroppedCount, which operators can scrape via Stats() and surface as an alert. Drains-on-Stop are idempotent (Stop may be called multiple times without panicking).

AsyncAuditLogger is itself a SecretStore — every CRUD method delegates to the wrapped store, while LogAudit is the only method that becomes asynchronous. This means callers can compose:

auditedStore := NewAsyncAuditLogger(pgStore, 4096)
svc := NewSecretService(keys, auditedStore)

and every audit write becomes non-blocking without further wiring.

func NewAsyncAuditLogger

func NewAsyncAuditLogger(store SecretStore, bufSize int, logger pkginterfaces.LoggerInterface) *AsyncAuditLogger

NewAsyncAuditLogger creates an async audit logger with a buffered channel. logger is optional — when set, drop+failure events surface at Warn so operators can detect audit-pipeline degradation.

func (*AsyncAuditLogger) AddBindings

func (l *AsyncAuditLogger) AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error

func (*AsyncAuditLogger) BindCredentialToAllUserWorkspaces

func (l *AsyncAuditLogger) BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error

func (*AsyncAuditLogger) CreateSecret

func (l *AsyncAuditLogger) CreateSecret(ctx context.Context, secret *UserSecret) error

func (*AsyncAuditLogger) DeleteSecret

func (l *AsyncAuditLogger) DeleteSecret(ctx context.Context, userID, secretID string) error

func (*AsyncAuditLogger) GetBindings

func (l *AsyncAuditLogger) GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)

func (*AsyncAuditLogger) GetBindingsForSecret

func (l *AsyncAuditLogger) GetBindingsForSecret(ctx context.Context, secretID string) ([]string, error)

func (*AsyncAuditLogger) GetSecret

func (l *AsyncAuditLogger) GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)

func (*AsyncAuditLogger) GetSecretByName

func (l *AsyncAuditLogger) GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)

func (*AsyncAuditLogger) GetWorkspaceCredentials

func (l *AsyncAuditLogger) GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)

func (*AsyncAuditLogger) HasUserProviderCredential

func (l *AsyncAuditLogger) HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)

func (*AsyncAuditLogger) ListGlobalDefaultSecrets

func (l *AsyncAuditLogger) ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)

func (*AsyncAuditLogger) ListSecrets

func (l *AsyncAuditLogger) ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)

func (*AsyncAuditLogger) LogAudit

func (l *AsyncAuditLogger) LogAudit(_ context.Context, entry *AuditEntry) (retErr error)

LogAudit on AsyncAuditLogger never blocks and never panics, even after Stop(). Entries are sent to the background goroutine via a buffered channel; a full channel drops the entry and increments the drop counter.

There is a small race window between the closed-flag check and the channel send where a concurrent Stop could close the channel out from under a sender. The deferred recover catches the resulting "send on closed channel" panic, increments the drop counter, and returns normally. Without the recover, a request emitting an audit entry concurrently with shutdown would crash the process.

The returned error is always nil — failures are observable via Stats() and the logger.

func (*AsyncAuditLogger) QueryAudit

func (l *AsyncAuditLogger) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)

func (*AsyncAuditLogger) ReEncryptUserSecrets

func (l *AsyncAuditLogger) ReEncryptUserSecrets(ctx context.Context, userID string, newKeyVersion int, transform func([]byte) ([]byte, error), commit func(context.Context) error) error

func (*AsyncAuditLogger) SeedWorkspaceCredentials

func (l *AsyncAuditLogger) SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error

func (*AsyncAuditLogger) SetBindings

func (l *AsyncAuditLogger) SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error

func (*AsyncAuditLogger) Stats

func (l *AsyncAuditLogger) Stats() AsyncAuditStats

Stats returns a snapshot of the dropped / written / failed counters. Safe to call concurrently with logging.

func (*AsyncAuditLogger) Stop

func (l *AsyncAuditLogger) Stop()

Stop drains the channel and waits for completion. Idempotent: a second call returns immediately without panicking on the already- closed channel. Sets the closed flag BEFORE closing the channel so any concurrent LogAudit invocations see closed=true and take the drop path rather than panicking on a send-to-closed-channel.

stopCtx is canceled AFTER the worker drains so a stuck-on-DB LogAudit eventually returns. There is still a small window between the closed-flag check and the channel send where a concurrent Stop could close the channel out from under a sender; the deferred recover in LogAudit catches that panic.

func (*AsyncAuditLogger) UpdateSecret

func (l *AsyncAuditLogger) UpdateSecret(ctx context.Context, secret *UserSecret) error

func (*AsyncAuditLogger) UpsertFreeTierCredential

func (l *AsyncAuditLogger) UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error

type AsyncAuditStats

type AsyncAuditStats struct {
	Dropped uint64 // entries dropped because the channel was full
	Written uint64 // entries successfully persisted
	Failed  uint64 // entries that reached the worker but the store rejected
}

AsyncAuditStats is the snapshot returned by AsyncAuditLogger.Stats.

type AuditEntry

type AuditEntry struct {
	ID          int64           `json:"id"`
	UserID      string          `json:"userId"`
	Action      string          `json:"action"`
	SecretID    *string         `json:"secretId,omitempty"`
	WorkspaceID *string         `json:"workspaceId,omitempty"`
	Metadata    json.RawMessage `json:"metadata,omitempty"`
	Timestamp   time.Time       `json:"timestamp"`
}

AuditEntry represents a secret audit log entry.

type AuditQuery

type AuditQuery struct {
	Action      string
	SecretID    string
	WorkspaceID string
	Since       *time.Time
	Until       *time.Time
	Limit       int
	Offset      int
}

AuditQuery defines filters for querying the audit log.

type AuditWriter

type AuditWriter interface {
	LogAudit(ctx context.Context, entry *AuditEntry) error
}

AuditWriter is the narrow interface for writing audit entries. Implemented by SecretStore, AsyncAuditLogger, and test doubles. US-50.12.

type AuditedProvider

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

AuditedProvider wraps a RootKeyProvider and logs every Decrypt call to the audit writer (US-50.12). Encrypt is NOT logged — it is not a sensitive read. The audit write is fire-and-forget (goroutine per call) so Decrypt never blocks on the audit pipeline. In production the AuditWriter is AsyncAuditLogger (already buffered), so the goroutine's LogAudit is a near-instant channel send.

What is logged: caller label, user ID (from context or "_system"), key version, timestamp, success/failure. Metadata is a JSON object.

What is NOT logged: plaintext, ciphertext, key material — never.

func NewAuditedProvider

func NewAuditedProvider(inner RootKeyProvider, audit AuditWriter, label string) *AuditedProvider

NewAuditedProvider wraps inner with an audit-decorating provider. label identifies the purpose string for log attribution.

func (*AuditedProvider) ActiveVersion

func (p *AuditedProvider) ActiveVersion() int

ActiveVersion delegates to the inner provider so the wrapper satisfies VersionedProvider. This is load-bearing: production callers invoke ActiveVersionOf(provider) at encrypt time to stamp the key_version column (auth.go, admin_provider_credentials.go, org_credentials.go). Without this delegation, wrapping with NewAuditedProvider would silently downgrade every key_version to the default 1, corrupting rotation tracking.

func (*AuditedProvider) Decrypt

func (p *AuditedProvider) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)

func (*AuditedProvider) Encrypt

func (p *AuditedProvider) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)

type AutoApplyRule

type AutoApplyRule struct {
	CredentialID string
	TargetType   string
	TargetID     *string
	Priority     int
}

AutoApplyRule is a row from credential_auto_apply (exported for handler use).

type BindingsMutationResult

type BindingsMutationResult struct {
	// LLMProviderAffected is true if any llm-provider secret was added or removed,
	// OR if the diff could not be computed (conservative fallback).
	LLMProviderAffected bool
	AddedTypes          []string
	RemovedTypes        []string
}

BindingsMutationResult describes what changed in a SetBindings or AddBindings call. Returned as a value (not pointer) so callers never need a nil check.

type BindingsResponse

type BindingsResponse struct {
	Bindings []BoundSecret `json:"bindings"`
}

BindingsResponse is the API response for workspace bindings.

type BoundSecret

type BoundSecret struct {
	SecretID string     `json:"secretId"`
	Name     string     `json:"name"`
	Type     SecretType `json:"type"`
}

BoundSecret is a secret reference in a binding response.

type CiphertextAudit added in v0.4.0

type CiphertextAudit struct {
	Table    string
	Total    int
	Target   int // rows whose prefix matches the migration target
	Legacy   int // un-prefixed pre-US-57.1 rows
	Local    int // lkms:v1:-prefixed rows (local provider, post-US-57.1)
	OtherKMS int // rows with a KMS prefix that isn't the target
}

CiphertextAudit summarizes the prefix distribution of one table after (or during) a migration. Used as the gate for removing the static fallback from the composite provider.

func (CiphertextAudit) IsComplete added in v0.4.0

func (a CiphertextAudit) IsComplete() bool

IsComplete returns true when every row in the table is on the target KMS provider. This is the safe-to-remove-static-fallback condition: the composite's static fallback can decrypt only legacy + lkms rows; any non-zero Legacy/Local/OtherKMS count means the fallback is still load-bearing.

Equivalent to `a.Target == a.Total` — when Total is 0, Target is also 0 (an empty table is trivially complete). The simpler form reads unambiguously without relying on && / || precedence.

func (CiphertextAudit) Outstanding added in v0.4.0

func (a CiphertextAudit) Outstanding() int

Outstanding returns the count of rows the static fallback still owns (Legacy + Local + OtherKMS). Equivalent to Total - Target but named for the operational question.

type CiphertextClass added in v0.4.0

type CiphertextClass int

CiphertextClass categorizes a row's ciphertext by which provider wrote it.

const (
	// ClassLegacy is an un-prefixed raw blob — the pre-US-57.1 production
	// format produced by Static/Sealed providers before the composite added
	// self-identifying prefixes.
	ClassLegacy CiphertextClass = iota
	// ClassLocal is an `lkms:v1:`-prefixed ciphertext written by a local
	// provider (Static/Sealed) after US-57.1.
	ClassLocal
	// ClassAWSKMS is an `aws-kms:v1:`-prefixed ciphertext from AWSKMSProvider.
	ClassAWSKMS
	// ClassGCPKMS is a `gcp-kms:v1:`-prefixed ciphertext from GPCKMSProvider.
	ClassGCPKMS
)

func ClassifyCiphertext added in v0.4.0

func ClassifyCiphertext(ciphertext []byte) CiphertextClass

ClassifyCiphertext inspects a ciphertext's prefix and returns the provider class that wrote it. Side-effect-free — does not call any provider, does not decrypt, does not touch the network. Safe to call on every row in the database at any time.

Classification is by prefix only. A corrupt ciphertext with a valid prefix (e.g. `aws-kms:v1:` followed by garbage) still classifies as ClassAWSKMS — that's the same ambiguity Decrypt has, and the audit's job is prefix accounting, not integrity checking. Integrity is the decrypt path's responsibility.

func (CiphertextClass) String added in v0.4.0

func (c CiphertextClass) String() string

String returns the human-readable class name for log/CLI output.

type CompositeProvider added in v0.3.0

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

CompositeProvider dispatches Encrypt/Decrypt across one primary and zero or more fallback RootKeyProvider instances. It exists to enable zero- downtime migration between provider types (local static/sealed ↔ cloud KMS) by routing Decrypt based on the ciphertext's self-identifying prefix.

Design (Epic 57 US-57.1, D3):

  • Encrypt always delegates to the primary. New writes carry the primary's prefix; the composite does not transform the output.

  • Decrypt iterates [primary, fallback...], calling each provider's Decrypt. Dispatch is governed by the providers themselves: each returns ErrNotMyCiphertext when the ciphertext's prefix doesn't match (see unwrapPrefix in root_key.go), ErrDecryptionFailed when the prefix matched but the key was wrong, or success. The composite stops at the first non-ErrNotMyCiphertext result — whether success or genuine decrypt failure — because continuing past a prefix match would produce spurious calls against every fallback for every corrupt row.

  • The composite does NOT implement VersionedProvider. Versioning is per-provider: Static has it; KMS providers track versions cloud- side. Callers needing ActiveVersion() type-assert on the primary via ActiveVersionOf, which returns 1 for non-VersionedProvider primaries — the safe default for the key_version column.

Thread safety: providers are constructed once at boot and never reassigned; the composite holds no mutable state. Safe for concurrent use after construction.

func NewCompositeProvider added in v0.3.0

func NewCompositeProvider(primary RootKeyProvider, fallbacks ...RootKeyProvider) (*CompositeProvider, error)

NewCompositeProvider constructs a composite from a required primary and zero or more optional fallbacks. Fallbacks are tried in the order supplied. Returns an error if primary is nil — a composite with no primary has no Encrypt target and would panic on the first write.

Returns an error if any fallback in the variadic tail is nil. A nil fallback would panic on Decrypt the first time dispatch reached that slot (typically the first foreign-prefix ciphertext under traffic), turning a "remove the static mount after migration" operator action into an API-pod crash loop. Failing closed at construction surfaces the misconfiguration at boot.

Callers that want a primary with no fallbacks should pass no variadic args at all: NewCompositeProvider(primary). The composite's Decrypt then routes only by the primary's prefix; foreign ciphertexts surface as ErrNotMyCiphertext — the same behavior the composite already exhibits when every fallback has been exhausted.

func (*CompositeProvider) Decrypt added in v0.3.0

func (c *CompositeProvider) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)

Decrypt iterates primary first, then fallbacks in registration order. It returns the first non-ErrNotMyCiphertext result — whether success or a genuine decrypt failure. If every provider returns ErrNotMyCiphertext (no provider recognizes the ciphertext's prefix), the composite returns ErrNotMyCiphertext so the caller knows the row is unroutable rather than corrupt.

The "stop on first non-routing result" invariant is load-bearing: a provider whose prefix matched but whose key didn't (ErrDecryptionFailed) must terminate dispatch, otherwise a single corrupt row would generate N-1 spurious decrypt calls (one per remaining provider) on every read. TestCompositeProvider_DecryptStopsOnPrefixMatch_NotErrNotMyCiphertext pins this behavior.

func (*CompositeProvider) Encrypt added in v0.3.0

func (c *CompositeProvider) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)

Encrypt delegates to the primary provider. The primary's output carries its self-identifying prefix (e.g. aws-kms:v1: or lkms:v1:); the composite does not transform it.

type CreateSecretRequest

type CreateSecretRequest struct {
	Name          string          `json:"name" binding:"required,min=1,max=255"`
	Type          SecretType      `json:"type" binding:"required"`
	Value         string          `json:"value" binding:"required"` // plaintext, encrypted before storage
	Metadata      json.RawMessage `json:"metadata"`
	GlobalDefault bool            `json:"globalDefault,omitempty"`
}

CreateSecretRequest is the API request for creating a secret.

type CredentialBinding

type CredentialBinding struct {
	ID                 string
	OwnerType          string
	OwnerID            string
	Kind               string // SDK-class enum (openai, anthropic, openai_compatible, ...)
	Slug               string // slug-safe per-owner identity; the agent-config.json provider-map key
	Ciphertext         []byte
	KeyVersion         int
	ModelAllowlist     []string
	ModelContextLimits map[string]int // model_id → context window size in tokens
	ModelOutputLimits  map[string]int // model_id → max output tokens
	SourceType         string         // "explicit" or "auto"
	WithinPriority     int
}

CredentialBinding is a joined row from workspace_credential_bindings + provider_credentials.

Identity model (Epic 55):

  • Kind: SDK-class discriminator. Enum constrained by the DB CHECK. Selects the adapter that opencode loads (openai, anthropic, bedrock, openai_compatible, ...). Multiple credentials of the same Kind can coexist (e.g. two OpenAI-compatible LiteLLM endpoints) — Kind does NOT have to be unique per owner.
  • Slug: stable per-owner identity. UNIQUE(owner_type, owner_id, slug) in the DB. This is also the literal key used in agent-config.json's provider map, so opencode sessions persist this value as providerID. A user with two `openai_compatible` credentials picks distinct slugs (e.g. "litellm-prod-us-west" and "litellm-prod-eu-central") to disambiguate them on the wire.

type CredentialBindingInfo

type CredentialBindingInfo struct {
	WorkspaceID string `json:"workspaceId"`
	SourceType  string `json:"sourceType"` // "explicit" or "auto"
}

CredentialBindingInfo is a minimal binding row used for the ListBindings API. It carries the workspace ID and the source type so the UI can distinguish auto-seeded bindings (which cannot be manually unbound) from explicit ones.

type CredentialRow

type CredentialRow struct {
	ID                 string
	OwnerType          string
	OwnerID            string
	Name               string // display label, free-form
	Kind               string // SDK-class enum
	Slug               string // per-owner unique identity; reaches opencode as providerID
	Ciphertext         []byte
	KeyVersion         int
	ModelAllowlist     []string
	ModelContextLimits map[string]int // model_id → context window size in tokens
	ModelOutputLimits  map[string]int // model_id → max response tokens
	CreatedAt          time.Time
	UpdatedAt          time.Time
}

CredentialRow is the DB row shape for all provider credential types. Maps to the provider_credentials table; owner_type discriminates the owner scope ("admin", "user", "org") and owner_id the concrete owner ("_platform", a user id, or an org id). Defined here to avoid an import cycle (handlers → secrets → handlers).

Epic 55 identity model:

  • Kind is the SDK-class enum (openai, anthropic, openai_compatible, ...). Multiple credentials of the same Kind can exist per owner.
  • Slug is the per-owner unique identity AND the literal provider-map key in agent-config.json. Slug-safe regex enforced by the DB CHECK.
  • Name is the free-form display label shown in the UI.

type CredentialStore

type CredentialStore interface {
	// GetWorkspaceCredentials returns all credential bindings for a workspace,
	// ordered by: (source_type='explicit') DESC, within_priority DESC, created_at ASC.
	GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)

	// UpsertFreeTierCredential atomically upserts the platform free-tier
	// credential row and its auto-apply rule in a single transaction.
	UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error

	// SeedWorkspaceCredentials inserts credential bindings for a new workspace:
	// admin auto-apply rules (all, user target types), user-owned credentials,
	// and org auto-apply rules + org credentials when orgID is non-nil.
	SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error

	// BindCredentialToAllUserWorkspaces binds a credential to every workspace
	// owned by userID. Called on credential create to maintain the invariant
	// that all credentials are bound to all of a user's workspaces.
	BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error

	// HasUserProviderCredential returns true if the user owns a credential
	// with the given slug. The lookup is per-slug because slug is the
	// per-owner unique identity (Epic 55); kind alone is not unique per
	// owner.
	HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)
}

CredentialStore abstracts database operations for provider credentials.

type DEKCache

type DEKCache interface {
	CacheDEK(ctx context.Context, sessionID string, dek []byte, ttl time.Duration) error
	GetDEK(ctx context.Context, sessionID string) ([]byte, error)
	EvictDEK(ctx context.Context, sessionID string) error
}

DEKCache abstracts session-based DEK caching (Redis).

type GPCKMSProvider added in v0.4.0

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

GPCKMSProvider implements RootKeyProvider using Google Cloud KMS for encrypt/decrypt operations. Parallel to AWSKMSProvider — same threat-model properties (KEK never leaves Google's HSM), different SDK and auth.

Auth is via file-mounted service-account JSON (D2: file-mount, not Workload Identity Federation — narrower trust surface).

One provider instance holds one KMS key resource name. Per-purpose domain separation (D4) is achieved by constructing multiple instances, one per purpose.

func NewGPCKMSProvider added in v0.4.0

func NewGPCKMSProvider(client *kms.KeyManagementClient, keyName string) *GPCKMSProvider

NewGPCKMSProvider constructs a provider from an SDK client and KMS key resource name (e.g. "projects/my-project/locations/us-east1/keyRings/ my-ring/cryptoKeys/my-key").

func (*GPCKMSProvider) Decrypt added in v0.4.0

func (p *GPCKMSProvider) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)

func (*GPCKMSProvider) Encrypt added in v0.4.0

func (p *GPCKMSProvider) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)

type InjectedSecret

type InjectedSecret struct {
	Type      SecretType      `json:"type"`
	Name      string          `json:"name"`
	Metadata  json.RawMessage `json:"metadata"`
	Plaintext string          `json:"plaintext"`
}

InjectedSecret is a single secret entry in the secrets.json file that the init container reads to materialize secrets.

type JWTSession

type JWTSession struct {
	JTI        uuid.UUID
	UserID     string
	WrappedDEK []byte
	KEKSalt    []byte
	CreatedAt  time.Time
	ExpiresAt  time.Time
}

JWTSession is the in-memory shape of a jwt_sessions row.

Layout matches migration 000045:

jwt_sessions(jti UUID PK, user_id TEXT FK, wrapped_dek BYTEA,
             kek_salt BYTEA, created_at TIMESTAMPTZ, expires_at TIMESTAMPTZ)

WrappedDEK is the user's DEK encrypted under a KEK derived from (matched_jwt_signing_key || jti) via HKDF-SHA256 with the llmsafespaces-jwt-session-dek-kek info string. See Epic 56 design.

JTI is the canonical UUID form that auth.go generates via uuid.New().String() and embeds in the JWT's "jti" claim.

type JWTSessionJanitor

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

JWTSessionJanitor periodically prunes expired rows from jwt_sessions so the table stays bounded as login traffic accrues. Mirrors the pattern in handlers.PendingOrgCleaner.

Failure handling: a single tick failure is logged and the next tick retries. We do NOT bail on persistent errors — recovering PG that was briefly unavailable is the same recovery path as a transient network blip, and surfacing the error any other way (panic, channel emit) adds complexity without benefit.

func NewJWTSessionJanitor

func NewJWTSessionJanitor(store JWTSessionStore, interval time.Duration, logger pkginterfaces.LoggerInterface) *JWTSessionJanitor

NewJWTSessionJanitor builds a janitor with the given store and interval. Pass interval=0 to use DefaultJWTSessionJanitorInterval.

func (*JWTSessionJanitor) Run

func (j *JWTSessionJanitor) Run(ctx context.Context)

Run blocks until ctx is canceled. Safe to call exactly once per janitor; mirror PendingOrgCleaner.Run.

type JWTSessionStore

type JWTSessionStore interface {
	// GetJWTSession fetches the durable row for jti. Returns (nil, nil)
	// when no row exists (the rehydrate path uses this signal — pre-feature
	// JWTs, expired-and-pruned rows, soft-unlock backfill cases).
	GetJWTSession(ctx context.Context, jti uuid.UUID) (*JWTSession, error)
	// WriteJWTSession upserts the row. Used by login (initial write) and
	// soft-unlock (backfill / US-50.4 rewrite). ON CONFLICT (jti) DO UPDATE
	// because a soft-unlock re-issues a fresh kek_salt + wrapped_dek for an
	// existing jti.
	WriteJWTSession(ctx context.Context, session *JWTSession) error
	// DeleteJWTSession removes the row for a specific jti. Used by EvictDEK
	// (logout, cache miss handling, etc.) so the durable row does not
	// outlive its Redis counterpart.
	DeleteJWTSession(ctx context.Context, jti uuid.UUID) error
	// DeleteJWTSessionsForUser removes all rows for a user. Used by
	// RevokeAllUserSessions (password reset / explicit logout-everywhere).
	// Returns the number of rows deleted.
	DeleteJWTSessionsForUser(ctx context.Context, userID string) (int64, error)
	// DeleteExpiredJWTSessions prunes rows with expires_at < before. The
	// janitor goroutine calls this on a ticker. Returns the number of rows
	// deleted. Bounded by the idx_jwt_sessions_expires_at index for O(log N)
	// scan even at 1M rows.
	DeleteExpiredJWTSessions(ctx context.Context, before time.Time) (int64, error)
	// ListActiveJWTSessionsForUser returns non-expired rows for userID,
	// ordered created_at DESC (most-recent first) and bounded by limit
	// (limit <= 0 means unlimited-per-caller-convention, though the
	// caller MUST supply a sensible bound in production).
	//
	// Used by KeyService.GetDEKForUser to retrieve a durable DEK-wrapping
	// row when the caller doesn't have a specific sessionID + matched
	// signing key (background paths: workspace watcher, auto-push
	// triggered by phase change or pod recreation). Every row for a
	// given user wraps the SAME DEK (user_keys has one row per user);
	// the caller only needs one row to unwrap.
	//
	// "Active" means expires_at is strictly AFTER the store's clock
	// (SQL: expires_at > NOW()). A row at the exact expires_at is
	// expired — matches DeleteExpiredJWTSessions's < semantics.
	//
	// Bounded by idx_jwt_sessions_user_id + a filter on expires_at
	// (partial index on expires_at could speed the AND but is not
	// required at current data scale).
	//
	// Returns nil (or []) with nil error when no matching rows exist —
	// callers use empty to signal "no live session; fall back to
	// SessionlessInject or ErrDEKUnavailable."
	ListActiveJWTSessionsForUser(ctx context.Context, userID string, limit int) ([]*JWTSession, error)
}

JWTSessionStore abstracts the durable jwt_sessions table for tests.

All methods are best-effort writes from the caller's perspective: login's hot path tolerates write failure (Redis cache still works), the revocation paths tolerate delete failure (Redis revocation is authoritative), and the janitor tolerates failure (it retries on the next tick). The DAL itself returns the underlying error verbatim so callers can log it without changing the surface.

type KEKMigrationError added in v0.4.0

type KEKMigrationError struct {
	RowID string
	Table string
	Error error
}

KEKMigrationError records a per-row failure.

type KEKMigrationResult added in v0.4.0

type KEKMigrationResult struct {
	Processed int
	Failed    int
	Errors    []KEKMigrationError
}

KEKMigrationResult summarizes a migration run.

type KEKRotationError

type KEKRotationError struct {
	RowID string
	Table string
	Error error
}

KEKRotationError records a per-row failure.

type KEKRotationResult

type KEKRotationResult struct {
	Processed int
	Skipped   int // rows already at target version
	Failed    int
	Errors    []KEKRotationError
}

KEKRotationResult summarizes a rotation run.

type KeyService

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

KeyService manages user key lifecycle.

func NewKeyService

func NewKeyService(store KeyStore, cache DEKCache) *KeyService

NewKeyService creates a new KeyService.

func (*KeyService) CacheDEK

func (s *KeyService) CacheDEK(ctx context.Context, sessionID string, dek []byte, ttl time.Duration) error

CacheDEK stores a DEK in the session cache. Used by API key auth to cache an unwrapped DEK under a deterministic sessionID.

func (*KeyService) ChangePassword

func (s *KeyService) ChangePassword(ctx context.Context, userID, sessionID string, oldPassword, newPassword []byte) error

ChangePassword re-wraps the DEK with a new password-derived KEK. Requires the old password to unwrap first. After the wrap is updated, the cached DEK for sessionID (the caller's current session) is evicted so the next request must re-Unlock with the new password — without this eviction a thief who has the JWT continues to read secrets via the cached DEK even after the user "rotates the password to be safe" (validator pass-3 finding P-1).

LIMITATION: this only evicts the caller's session. A user with multiple active sessions on different devices retains those cached DEKs until they expire naturally. We document the limitation in the API rather than rebuild the cache for cross- session enumeration.

sessionID may be empty (e.g. tests, internal callers without a session); eviction is then a no-op.

func (*KeyService) DEKAvailable

func (s *KeyService) DEKAvailable(ctx context.Context, sessionID string) bool

DEKAvailable checks if a DEK is cached for the given session.

func (*KeyService) DeleteDurableSessionsForUser

func (s *KeyService) DeleteDurableSessionsForUser(ctx context.Context, userID string) error

DeleteDurableSessionsForUser removes every jwt_sessions row for a user. Called by auth.Service.RevokeAllUserSessions (password reset, admin force-logout) so a stolen JWT cannot rehydrate the DEK from the durable store after the user has explicitly invalidated every outstanding session. Best-effort: failure is logged but does not propagate — the Redis revocation markers are already in place and the JWT itself is functionally dead.

Returns nil even on failure — callers do not need to handle the error path; the contract is "drive jwt_sessions toward consistency with the auth-layer revocation, log if we can't".

func (*KeyService) EvictDEK

func (s *KeyService) EvictDEK(ctx context.Context, sessionID string) error

EvictDEK removes the cached DEK for a session AND the durable jwt_sessions row (Epic 56). Called on logout / explicit revocation. Non-JTI sessionIDs (API-key sessions like "apikey:hash") only evict the Redis cache — the api_keys table is the durable home for those.

func (*KeyService) GetDEK

func (s *KeyService) GetDEK(ctx context.Context, sessionID string, matchedSigningKey []byte) ([]byte, error)

GetDEK retrieves the DEK for a session.

Resolution order (Epic 56):

  1. Redis cache hit → return cached DEK (fast path; no DB).
  2. Redis cache miss + matchedSigningKey supplied + sessionID is a UUID → attempt durable rehydrate from jwt_sessions: a. Row missing → ErrDEKUnavailable (soft-unlock will backfill). b. Row expired → ErrDEKUnavailable (janitor will prune; client should re-login since the JWT is itself near/past expiry). c. Unwrap failure → ErrDEKUnavailable (post-rotation, US-50.4 DEK rotation, or row corruption — soft-unlock recovers). d. Success → re-cache to Redis, return DEK.
  3. Anything else → ErrDEKUnavailable.

matchedSigningKey is the JWT signing key that validated the caller's token. Pass nil for non-JWT auth (API keys, controller-internal callers); those paths cannot rehydrate (no KEK material) and will surface ErrDEKUnavailable — the correct behavior, since the API-key auth has its own DEK persistence (api_keys.WrappedDEK) and controller-internal callers do not need user-DEK content.

Redis errors (other than miss) are logged at Warn but DO NOT block the rehydrate attempt: in a Redis-outage + valid-durable-row scenario, rehydrate is exactly the resilience the epic provides. The previous "fail closed on any cache error" behavior is preserved only for the "no rehydrate available" sub-case.

func (*KeyService) GetDEKForUser

func (s *KeyService) GetDEKForUser(ctx context.Context, userID string) (dek []byte, jti string, err error)

GetDEKForUser retrieves the user's DEK without requiring a specific sessionID or matchedSigningKey from the caller. Designed for background paths (workspace watcher, controller-triggered auto-push after pod recreation, etc.) that need to deliver user-DEK content but do not run in an authenticated user-request context.

Returns (dek, jti, error). The jti is the jwt_sessions row's primary key — callers use it as a sessionID when building an agentpush.WithAuth context so that InjectSecrets' subsequent GetDEK(sessionID, matchedSigningKey) call hits the Redis cache this method just populated. Without returning jti, the caller would have no way to reference the DEK just cached, and would re-execute the unwrap on every downstream call.

Resolution order (worklog 0590):

  1. jwtSessions.ListActiveJWTSessionsForUser(userID, LIMIT) → candidate rows. If empty → ErrDEKUnavailable (no live session for the user; caller falls back to SessionlessInject or logs).
  2. For each row (most-recent first), check the Redis cache under the row's jti. On hit → return the cached DEK (fast path; avoids KDF + AEAD-decrypt).
  3. On cache miss for that jti, iterate signingKeys.EachSigningKey. For each candidate key, derive KEK = HKDF(key || jti, kekSalt, JWTSessionKEKInfo) and attempt DecryptSecret. First success → write-back to Redis under this jti so subsequent GetDEK(jti, matchedKey) calls hit the fast path, and return the DEK.
  4. If NO signing key can unwrap the most-recent row: continue to next row (older sessions may have been wrapped under an even older signing key that this API instance still knows). If all rows exhausted → ErrDEKUnavailable.

Cache-hit short-circuit (step 2) is what makes this safe to call repeatedly for the same user: after the first successful call, all subsequent calls hit Redis in O(1). Only cold-Redis or genuine cache-miss paths do PG+KDF work.

Rows are bounded (LIMIT jwtSessionUserLookupLimit) to prevent pathological unwrap-loops if a user has thousands of sessions.

Errors: ErrDEKUnavailable is used for every legitimate "no user context available" case (no active session, no signing key unwraps, no jwtSessions or signingKeys wired). Genuine infrastructure errors (PG connection failure, cache client fault) are returned verbatim so operators can distinguish debug-worthy outages from expected "user logged out" cases.

func (*KeyService) HasKeys

func (s *KeyService) HasKeys(ctx context.Context, userID string) (bool, error)

HasKeys checks if a user has key material initialized.

func (*KeyService) InitializeUserKeys

func (s *KeyService) InitializeUserKeys(ctx context.Context, userID string, password []byte) (recoveryKeyHex string, err error)

InitializeUserKeys generates a DEK and wraps it with the user's password-derived KEK. Called during account creation or first secret creation for existing users. Returns the recovery key (hex-encoded) that must be displayed to the user once.

func (*KeyService) JWTSessionStoreSet

func (s *KeyService) JWTSessionStoreSet() bool

JWTSessionStoreSet reports whether a JWT-session store has been wired. Exposed so app.go wiring + tests can assert post-init invariants without reaching into private state.

func (*KeyService) ResetWithRecoveryKey

func (s *KeyService) ResetWithRecoveryKey(ctx context.Context, userID string, recoveryKeyHex string, newPassword []byte) (newRecoveryKeyHex string, err error)

ResetWithRecoveryKey unwraps the DEK using the recovery key and re-wraps with a new password. Returns a new recovery key (hex-encoded).

func (*KeyService) RotateKeyWithPassword

func (s *KeyService) RotateKeyWithPassword(ctx context.Context, userID string, password []byte, sessionID string, ttl time.Duration) (RotationResult, error)

RotateKeyWithPassword rotates the user's DEK and eagerly re-encrypts every secret row under the new DEK in a single transaction.

The flow is:

  1. Verify the password by unwrapping the current DEK with the derived KEK.
  2. Generate a new random DEK.
  3. Generate a new recovery key + salt; the old recovery key wraps the old (about-to-be-discarded) DEK and would be useless after rotation. Without this, ResetWithRecoveryKey post-rotate would unwrap a DEK that no longer matches user_secrets.
  4. Walk all user_secrets rows; decrypt each with the old DEK and re-encrypt with the new DEK. The store implementation runs this under a single atomic operation so partial failures cannot leave orphaned rows.
  5. Wrap the new DEK with the same KEK and bump key_version, INSIDE the same tx (commit closure). Wrap newDEK with the new recoveryKEK and update user_keys.wrapped_dek_recovery in the same tx.
  6. Refresh the session DEK cache.

If any step in 4 or 5 fails, the entire tx rolls back: secrets stay at the old key_version, user_keys keeps the old wrapped DEK, the old recovery key still works. The rotation is a no-op from the client's perspective (modulo the function's error return).

SetSecretStore must be called before RotateKeyWithPassword; otherwise the function refuses to run.

func (*KeyService) SetAPIKeyStore

func (s *KeyService) SetAPIKeyStore(store APIKeyStore, provider RootKeyProvider)

SetAPIKeyStore wires the API key store for DEK re-wrap on rotation.

func (*KeyService) SetJWTSessionStore

func (s *KeyService) SetJWTSessionStore(store JWTSessionStore)

SetJWTSessionStore wires the durable jwt_sessions table backing the GetDEK rehydrate path. Optional — tests and pre-Epic-56 callers may leave it nil; GetDEK then behaves Redis-only (cache miss ⇒ error).

Like SetSecretStore, silent rebinding to a different store is refused: the durable rehydrate would otherwise read from a store that holds no rows for the active session set, surfacing as a wave of ErrDEKUnavailable across all live JWTs. Idempotent same-store calls are allowed.

func (*KeyService) SetLogger

func (s *KeyService) SetLogger(l pkginterfaces.LoggerInterface)

SetLogger installs the logger used to surface non-fatal failures (e.g. cache-evict errors during password change). Optional; if nil, those events are silent. Validator pass-5 finding N-3.

Note: ChangePassword's evict-failure log includes the sessionID (JWT jti). The jti is sensitive — an attacker with log read access can correlate user activity across requests, though it does NOT enable token replay (the JWT signature is never logged). Volume is bounded to Redis-outage events. If the log retention crosses a tenant boundary, hash sessionID before logging.

func (*KeyService) SetSecretStore

func (s *KeyService) SetSecretStore(store SecretStore)

SetSecretStore wires the SecretStore used by RotateKeyWithPassword to re-encrypt every user_secrets row under the new DEK. Without this, the rotate endpoint refuses to run rather than orphan secret rows under a discarded DEK (Bug 9 in worklog 0085).

Once set, the store cannot be silently reassigned: a silent reassignment would mean RotateKeyWithPassword ignores secrets owned by an abandoned store — exactly the Bug 9 hazard. Calling SetSecretStore twice with different stores panics; calling with the same store (idempotent re-init) is allowed.

func (*KeyService) SetSigningKeyEnumerator

func (s *KeyService) SetSigningKeyEnumerator(e SigningKeyEnumerator)

SetSigningKeyEnumerator installs the signing-key enumerator. Optional setter (not New arg) because auth.Service is constructed later in app.New; setter-DI is the existing pattern for these late-arrival deps.

Unlike SetJWTSessionStore / SetSecretStore, this setter intentionally has NO double-set panic guard. Rebinding the enumerator cannot cause silent data inconsistency the way rebinding a store can: the worst case is that a subsequent GetDEKForUser call fails to unwrap a row (because the new enumerator returned different keys than were used to wrap that row), which surfaces as ErrDEKUnavailable — the same sentinel the "no session" path uses. The caller falls back cleanly. A double-set panic here would forbid legitimate hot-swap scenarios (test harnesses, key-rotation live-reload) without a corresponding safety benefit.

func (*KeyService) UnlockDEK

func (s *KeyService) UnlockDEK(ctx context.Context, userID string, password []byte, sessionID string, ttl time.Duration) error

UnlockDEK derives the KEK from the password, unwraps the DEK, and caches it. Called during login. sessionID is the JWT's jti claim.

This is the pre-Epic-56 entry point — Redis cache only. Use UnlockDEKWithSigningKey from the login site to additionally write the durable jwt_sessions row (Epic 56). Internal callers (auth.Login) always go through the With-SigningKey variant; tests and Register (which has no JWT yet at the point of call) use this one.

func (*KeyService) UnlockDEKWithSigningKey

func (s *KeyService) UnlockDEKWithSigningKey(ctx context.Context, userID string, password []byte, sessionID string, ttl time.Duration, activeSigningKey []byte) error

UnlockDEKWithSigningKey is UnlockDEK + durable jwt_sessions write (Epic 56). The durable row is wrapped under a KEK derived from activeSigningKey || jti via HKDF-SHA256; the rehydrate path (rehydrateDEKFromJWTSession) re-derives the same KEK from the MATCHED signing key recovered from a presented JWT.

Behavior matrix:

  • activeSigningKey == nil → Redis cache only; no durable write. This is the path tests and Register take. The legacy UnlockDEK delegates here with nil.

  • sessionID is not a UUID → Redis cache only. API-key sessions ("apikey:hash") and legacy non-UUID sessionIDs don't belong in jwt_sessions; the api_keys.WrappedDEK design covers API-key DEK durability separately.

  • jwtSessions store not wired → Redis cache only. Pre-Epic-56 deploys and tests without SetJWTSessionStore.

  • durable write fails → NOT returned as an error. The Redis cache succeeded, so the JWT is functional for its remaining lifetime; only the durable rehydrate-on-Valkey-restart property is degraded. Log Warn so operators see the loss of resilience. Login MUST NOT fail on a transient PG hiccup.

"activeSigningKey" name is precise: at login the JWT we just issued is signed with s.jwtSecret (active), so we derive against the active key. The rehydrate path may match a previous key if rotation happens between issue and use — that's expected; what matters is the KEY at JWT-validation time, surfaced via parseTokenAcceptingRotatedKeys.

type KeyStore

type KeyStore interface {
	GetUserKey(ctx context.Context, userID string) (*UserKeyRecord, error)
	CreateUserKey(ctx context.Context, record *UserKeyRecord) error
	UpdateWrappedDEK(ctx context.Context, userID string, wrappedDEK []byte, salt []byte, keyVersion int) error
	UpdateWrappedDEKRecovery(ctx context.Context, userID string, wrappedDEKRecovery []byte, recoverySalt []byte) error
}

KeyStore abstracts database operations for user keys.

type LLMModelConfig

type LLMModelConfig struct {
	ID           string `json:"id"`
	Label        string `json:"label,omitempty"`
	ContextLimit int    `json:"contextLimit,omitempty"`
	OutputLimit  int    `json:"outputLimit,omitempty"`
}

LLMModelConfig declares an allowlisted model and its display/limit metadata. At minimum, ID is required. Label is shown in pickers when set.

ContextLimit and OutputLimit drive the opencode `limit` block in agent-config.json. opencode's published JSON Schema (https://opencode.ai/config.json) declares the model `limit` object with `"required": ["context", "output"]` and `"additionalProperties": false`. Therefore FormatOpenCodeConfig emits a `limit` block ONLY when BOTH are non-zero — emitting a partial block (only context, or only output) makes opencode 1.15.12 reject the entire config with SchemaError: Missing key, which causes every endpoint that calls Config.state() (including POST /session) to return 500.

ContextLimit is the total context window size in tokens. When set together with OutputLimit it is written into agent-config.json as limit.context, which makes opencode's /config/providers return ctx=N, which feeds ModelContextLimit() in agentd → context.total_tokens in /v1/statusz → CRD status.contextTotal → the frontend's "used / total" context bar.

OutputLimit is the maximum response tokens for the model. opencode uses this for compaction sizing. Like ContextLimit it cannot be auto-discovered from a provider's /v1/models endpoint and must be configured explicitly by the workspace/credential owner.

type LLMProviderData

type LLMProviderData struct {
	Kind       string           `json:"kind"`
	Slug       string           `json:"slug"`
	APIKey     string           `json:"apiKey"`
	BaseURL    string           `json:"baseURL,omitempty"`
	Models     []LLMModelConfig `json:"models,omitempty"`
	Default    string           `json:"default,omitempty"`
	SmallModel string           `json:"smallModel,omitempty"`
}

LLMProviderData holds structured credentials for one LLM provider. The Plaintext value of an "llm-provider" secret is the JSON encoding of this struct.

Epic 55 identity model:

  • Kind is the SDK-class enum (openai, anthropic, openai_compatible, ...). Required. Determines which adapter opencode loads.
  • Slug is the per-owner unique identity AND the literal key used in agent-config.json's provider map. opencode persists this as `providerID` on sessions.

APIKey is required. BaseURL is optional; when empty the provider's default endpoint is used. Models is an optional allowlist. When empty or nil all models from the provider are visible (no filtering). When non-empty only the listed models are shown. Default is the model ID to use when no per-session model is specified. SmallModel is the model ID used for lightweight/cheap operations (e.g. summarization).

func (LLMProviderData) Validate

func (d LLMProviderData) Validate() error

Validate checks that required fields are set in LLMProviderData.

type MigrationCoordinator added in v0.4.0

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

MigrationCoordinator re-encrypts KEK-protected rows from source (CompositeProvider: KMS-primary + local-fallback) to target (KMS-only provider). It is table-agnostic and supports dry-run + resume-from-cursor for safe operation.

func NewMigrationCoordinator added in v0.4.0

func NewMigrationCoordinator(
	store MigrationStore,
	sources map[string]RootKeyProvider,
	targets map[string]RootKeyProvider,
) *MigrationCoordinator

NewMigrationCoordinator constructs a coordinator. Each map is keyed by purpose string and should contain the same set of keys.

func (*MigrationCoordinator) AuditAll added in v0.4.0

func (c *MigrationCoordinator) AuditAll(ctx context.Context, target string) (map[string]CiphertextAudit, error)

AuditAll walks all three KEK-protected tables and returns their audits as a map keyed by table name. The static fallback can be safely removed only when every table's IsComplete() returns true.

func (*MigrationCoordinator) AuditTable added in v0.4.0

func (c *MigrationCoordinator) AuditTable(ctx context.Context, table, target string) (CiphertextAudit, error)

AuditTable walks the given table and returns the prefix distribution. `target` is the operator's intended final KMS ("aws-kms" or "gcp-kms") and determines which prefix counts as Target vs OtherKMS. No writes, no provider calls, no decrypts — pure prefix accounting. Safe to run at any time, including against a live deployment mid-traffic.

func (*MigrationCoordinator) MigrateAll added in v0.4.0

func (c *MigrationCoordinator) MigrateAll(ctx context.Context, dryRun bool) (map[string]KEKMigrationResult, error)

MigrateAll re-encrypts all three tables sequentially. The Redis DEK cache is flushed after all tables complete successfully.

func (*MigrationCoordinator) MigrateTable added in v0.4.0

func (c *MigrationCoordinator) MigrateTable(ctx context.Context, table, resumeFromID string, dryRun bool) (KEKMigrationResult, error)

MigrateTable re-encrypts every row in the given table from source format to target format. If dryRun is true, no writes occur — only counts are reported. resumeFromID allows resuming after an interrupted run.

type MigrationRow added in v0.4.0

type MigrationRow struct {
	ID         string
	Table      string
	OwnerType  string
	Ciphertext []byte
	KeyVersion int
}

MigrationRow is a generic row from any KEK-protected table. The coordinator re-encrypts Ciphertext from the source format (any provider — local or KMS) to the target format (KMS only).

type MigrationStore added in v0.4.0

type MigrationStore interface {
	// ListMigrationRows returns rows from the given table, ordered by
	// ID ASC, starting after resumeFromID (empty = from the beginning).
	// limit caps the batch size (0 = unlimited).
	ListMigrationRows(ctx context.Context, table, resumeFromID string, limit int) ([]MigrationRow, error)

	// UpdateMigrationRow writes newCiphertext + newKeyVersion atomically
	// for the given row. Each call is its own transaction.
	UpdateMigrationRow(ctx context.Context, table, rowID string, newCiphertext []byte, newKeyVersion int) error

	// FlushDEKCache flushes the Redis DEK cache so stale DEKs (wrapped
	// under the old KEK) are evicted.
	FlushDEKCache(ctx context.Context) error
}

MigrationStore abstracts the three KEK-protected tables for the migration CLI. Each method returns rows in a consistent order (by ID ASC). Mirrors RotationStore — see rotation.go.

type OrgAutoApplyStore

type OrgAutoApplyStore interface {
	BindCredentialToAllOrgWorkspaces(ctx context.Context, credentialID, orgID string) error
	CreateOrgAutoApply(ctx context.Context, credentialID, orgID string, withinPriority int) error
	ListOrgAutoApply(ctx context.Context, orgID string) ([]*AutoApplyRule, error)
	DeleteOrgAutoApply(ctx context.Context, credentialID, orgID string) error
}

OrgAutoApplyStore is the DB interface for org-scoped auto-apply and workspace-binding operations (the credential CRUD itself is served by the owner-parameterized CredentialStore methods on PgSecretStore).

type OwnerType

type OwnerType string

OwnerType distinguishes user-owned from org-owned secrets.

const (
	OwnerTypeUser  OwnerType = "user"
	OwnerTypeOrg   OwnerType = "org"
	OwnerTypeAdmin OwnerType = "admin"
)

type PgJWTSessionStore

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

PgJWTSessionStore is the production JWTSessionStore backed by Postgres.

func NewPgJWTSessionStore

func NewPgJWTSessionStore(pool *pgxpool.Pool) *PgJWTSessionStore

NewPgJWTSessionStore creates a new PostgreSQL-backed JWTSessionStore.

func (*PgJWTSessionStore) DeleteExpiredJWTSessions

func (s *PgJWTSessionStore) DeleteExpiredJWTSessions(ctx context.Context, before time.Time) (int64, error)

DeleteExpiredJWTSessions prunes rows whose expires_at is strictly before the provided cutoff. The janitor passes time.Now(); tests pass a fixed clock for determinism. Returns the count for logging and monitoring (a sudden spike in deletes flags a clock skew or a large rotation event).

func (*PgJWTSessionStore) DeleteJWTSession

func (s *PgJWTSessionStore) DeleteJWTSession(ctx context.Context, jti uuid.UUID) error

DeleteJWTSession removes the row for jti. Idempotent: deleting a non-existent row is not an error (the DELETE returns rowsAffected=0).

func (*PgJWTSessionStore) DeleteJWTSessionsForUser

func (s *PgJWTSessionStore) DeleteJWTSessionsForUser(ctx context.Context, userID string) (int64, error)

DeleteJWTSessionsForUser removes every row for userID. Used by RevokeAllUserSessions so a password-reset cascade leaves no durable DEK row behind. Returns the number of rows deleted so the caller can audit-log the magnitude.

func (*PgJWTSessionStore) GetJWTSession

func (s *PgJWTSessionStore) GetJWTSession(ctx context.Context, jti uuid.UUID) (*JWTSession, error)

GetJWTSession returns the durable row for jti, or (nil, nil) when none exists.

func (*PgJWTSessionStore) ListActiveJWTSessionsForUser

func (s *PgJWTSessionStore) ListActiveJWTSessionsForUser(ctx context.Context, userID string, limit int) ([]*JWTSession, error)

ListActiveJWTSessionsForUser returns non-expired rows for userID. See interface godoc for full contract. Uses idx_jwt_sessions_user_id (schema 000001) plus a filter on expires_at > NOW(). At current data scale (thousands of sessions per active user max) the trailing filter on expires_at is a scan of the per-user rows only; no compound index needed. If per-user row counts grow past ~10k a partial index (WHERE expires_at > NOW()) can be added later without changing this query.

The NOW() comparison is inline in the SQL so the database's clock is authoritative — same source of truth the janitor uses when it prunes. Fetching a "just barely expired" row and having the caller discover it a millisecond later would waste a signing-key iteration and produce a misleading warn log.

When limit <= 0, no LIMIT clause is added. Callers should always pass a sensible bound (KeyService.GetDEKForUser passes 5, per jwtSessionUserLookupLimit — covers multi-row fallback while preventing pathological unwrap-loops on users with many sessions).

func (*PgJWTSessionStore) WriteJWTSession

func (s *PgJWTSessionStore) WriteJWTSession(ctx context.Context, session *JWTSession) error

WriteJWTSession upserts the row. The primary key is jti, so duplicate writes for the same jti (soft-unlock backfill, US-50.4 rewrite, or two near-simultaneous logins racing on the same uuid — astronomically unlikely but defended against) overwrite the previous wrapped_dek and kek_salt. user_id and created_at are preserved on conflict because they describe identity, not state; only the KEK material rotates.

type PgKeyStore

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

PgKeyStore implements KeyStore using PostgreSQL.

func NewPgKeyStore

func NewPgKeyStore(pool *pgxpool.Pool) *PgKeyStore

NewPgKeyStore creates a new PostgreSQL-backed key store.

func (*PgKeyStore) CreateUserKey

func (s *PgKeyStore) CreateUserKey(ctx context.Context, record *UserKeyRecord) error

CreateUserKey stores a user's key material. On conflict (the user already has a row — e.g. password reset reinitializing a fresh DEK for a user who already has key material), the row is overwritten. This is required because user_keys.user_id is the PRIMARY KEY and a plain INSERT would fail with unique_violation for any user who has ever created a secret, which is the only case where reinit matters. Overwriting with a freshly-generated DEK (see InitializeUserKeys) is exactly the desired reset behavior: the prior wraps and anything encrypted under the prior DEK become permanently undecryptable.

func (*PgKeyStore) GetUserKey

func (s *PgKeyStore) GetUserKey(ctx context.Context, userID string) (*UserKeyRecord, error)

func (*PgKeyStore) UpdateWrappedDEK

func (s *PgKeyStore) UpdateWrappedDEK(ctx context.Context, userID string, wrappedDEK []byte, salt []byte, keyVersion int) error

UpdateWrappedDEK updates the wrapped DEK for a user. When the context carries an active *pgx.Tx (threaded through by SecretStore.ReEncryptUserSecrets via withTx), the UPDATE runs inside that transaction so the user_keys row and the user_secrets re-encrypt commit or roll back atomically. Otherwise the UPDATE runs on the pool directly. See Bug 9 in worklog 0094.

func (*PgKeyStore) UpdateWrappedDEKRecovery

func (s *PgKeyStore) UpdateWrappedDEKRecovery(ctx context.Context, userID string, wrappedDEKRecovery []byte, recoverySalt []byte) error

UpdateWrappedDEKRecovery updates the recovery-key wrap. Like UpdateWrappedDEK, the implementation honors an active *pgx.Tx threaded through the context (via withTx) so future callers that want to bundle a recovery-key rotation into the same atomic unit as the password-key rotation can do so. No current caller does, but the parity with UpdateWrappedDEK closes a latent footgun.

type PgSecretStore

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

PgSecretStore implements SecretStore using PostgreSQL.

func NewPgSecretStore

func NewPgSecretStore(pool *pgxpool.Pool) *PgSecretStore

NewPgSecretStore creates a new PostgreSQL-backed secret store.

func (*PgSecretStore) AddBindings

func (s *PgSecretStore) AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error

AddBindings atomically adds secretIDs to a workspace's binding set without touching existing bindings. Takes the same advisory lock as SetBindings so the two cannot interleave dangerously.

The INSERT uses ON CONFLICT DO NOTHING so re-binding an already- bound secret is idempotent rather than a constraint violation.

func (*PgSecretStore) BackfillFreeTierBindings

func (s *PgSecretStore) BackfillFreeTierBindings(ctx context.Context) (int64, error)

BackfillFreeTierBindings inserts workspace_credential_bindings for all existing workspaces that lack the free-tier opencode credential binding. Idempotent — uses ON CONFLICT DO NOTHING. Returns the number of rows inserted.

func (*PgSecretStore) BindAllOrgCredentialsToOrgWorkspaces

func (s *PgSecretStore) BindAllOrgCredentialsToOrgWorkspaces(ctx context.Context, orgID string) error

BindAllOrgCredentialsToOrgWorkspaces binds every org credential to every non-deleted org workspace. Used after invitation acceptance (F7) to ensure newly-migrated workspaces receive the org's shared credentials immediately. Existing bindings are skipped (ON CONFLICT DO NOTHING). Best-effort: callers should log errors but not fail the user-facing operation.

func (*PgSecretStore) BindCredentialToAllOrgWorkspaces

func (s *PgSecretStore) BindCredentialToAllOrgWorkspaces(ctx context.Context, credentialID, orgID string) error

func (*PgSecretStore) BindCredentialToAllUserWorkspaces

func (s *PgSecretStore) BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error

BindCredentialToAllUserWorkspaces binds a user credential to every workspace owned by userID. Called when a user creates a new personal credential so that the invariant "all credentials bound to all workspaces" is maintained. Idempotent.

func (*PgSecretStore) BindCredentialToWorkspace

func (s *PgSecretStore) BindCredentialToWorkspace(ctx context.Context, credentialID, workspaceID string) error

BindCredentialToWorkspace explicitly binds a credential to a workspace.

func (*PgSecretStore) CreateAutoApply

func (s *PgSecretStore) CreateAutoApply(ctx context.Context, credentialID, targetType string, targetID *string, priority int) error

CreateAutoApply inserts an auto-apply rule.

func (*PgSecretStore) CreateCredential

func (s *PgSecretStore) CreateCredential(ctx context.Context, ownerType, ownerID string, row *CredentialRow) error

CreateCredential inserts a provider credential scoped by (ownerType, ownerID). The caller supplies a pre-generated ID (uuid.New().String()), matching the admin/user pattern; the DB DEFAULT gen_random_uuid() is only a fallback.

func (*PgSecretStore) CreateOrgAutoApply

func (s *PgSecretStore) CreateOrgAutoApply(ctx context.Context, credentialID, orgID string, withinPriority int) error

func (*PgSecretStore) CreateSecret

func (s *PgSecretStore) CreateSecret(ctx context.Context, secret *UserSecret) error

func (*PgSecretStore) DeleteAutoApply

func (s *PgSecretStore) DeleteAutoApply(ctx context.Context, credentialID, targetType string, targetID *string) error

DeleteAutoApply removes an auto-apply rule.

func (*PgSecretStore) DeleteCredential

func (s *PgSecretStore) DeleteCredential(ctx context.Context, ownerType, ownerID, credID string) error

DeleteCredential deletes a credential by ID scoped to (ownerType, ownerID). FK cascades handle bindings. Returns pgx.ErrNoRows if no row was deleted so callers can distinguish 404 (L-1 fix).

func (*PgSecretStore) DeleteOrgAutoApply

func (s *PgSecretStore) DeleteOrgAutoApply(ctx context.Context, credentialID, orgID string) error

func (*PgSecretStore) DeleteSecret

func (s *PgSecretStore) DeleteSecret(ctx context.Context, userID, secretID string) error

func (*PgSecretStore) GetBindings

func (s *PgSecretStore) GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)

func (*PgSecretStore) GetBindingsForSecret

func (s *PgSecretStore) GetBindingsForSecret(ctx context.Context, secretID string) ([]string, error)

func (*PgSecretStore) GetCredential

func (s *PgSecretStore) GetCredential(ctx context.Context, ownerType, ownerID, credID string) (*CredentialRow, error)

GetCredential returns a single credential by ID scoped to (ownerType, ownerID), or nil if not found. Filtering on both owner_type AND owner_id preserves the L-4 defensive multi-admin safety of the former admin path.

func (*PgSecretStore) GetCredentialBindings

func (s *PgSecretStore) GetCredentialBindings(ctx context.Context, credentialID, ownerID string) ([]string, error)

GetCredentialBindings returns workspace IDs the credential is bound to, scoped to workspaces owned by ownerID.

func (*PgSecretStore) GetCredentialBindingsWithSource

func (s *PgSecretStore) GetCredentialBindingsWithSource(ctx context.Context, credentialID, ownerID string) ([]CredentialBindingInfo, error)

GetCredentialBindingsWithSource returns workspace IDs and source type for bindings, scoped to workspaces owned by ownerID (M-1 fix: allows UI to distinguish auto vs explicit).

func (*PgSecretStore) GetSecret

func (s *PgSecretStore) GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)

func (*PgSecretStore) GetSecretByName

func (s *PgSecretStore) GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)

func (*PgSecretStore) GetWorkspaceCredentials

func (s *PgSecretStore) GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)

GetWorkspaceCredentials returns all credential bindings for a workspace, ordered by: (source_type='explicit') DESC, within_priority DESC, created_at ASC.

func (*PgSecretStore) HasUserProviderCredential

func (s *PgSecretStore) HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)

HasUserProviderCredential returns true if the user owns a credential with the given slug.

func (*PgSecretStore) ListAutoApply

func (s *PgSecretStore) ListAutoApply(ctx context.Context, credentialID string) ([]AutoApplyRule, error)

ListAutoApply returns all auto-apply rules for a credential.

func (*PgSecretStore) ListCredentials

func (s *PgSecretStore) ListCredentials(ctx context.Context, ownerType, ownerID string) ([]*CredentialRow, error)

ListCredentials returns all credentials owned by (ownerType, ownerID), ordered by created_at ASC.

func (*PgSecretStore) ListGlobalDefaultSecrets

func (s *PgSecretStore) ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)

ListGlobalDefaultSecrets returns all secrets owned by userID that have global_default=true. Used when seeding bindings on workspace creation.

func (*PgSecretStore) ListOrgAutoApply

func (s *PgSecretStore) ListOrgAutoApply(ctx context.Context, orgID string) ([]*AutoApplyRule, error)

func (*PgSecretStore) ListSecrets

func (s *PgSecretStore) ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)

func (*PgSecretStore) LogAudit

func (s *PgSecretStore) LogAudit(ctx context.Context, entry *AuditEntry) error

func (*PgSecretStore) QueryAudit

func (s *PgSecretStore) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)

func (*PgSecretStore) ReEncryptOrgCredentials

func (s *PgSecretStore) ReEncryptOrgCredentials(ctx context.Context, tx pgx.Tx, orgID string, oldDEK, newDEK []byte) (int, error)

ReEncryptOrgCredentials re-encrypts all provider_credentials rows where owner_type='org' AND owner_id=orgID atomically within tx.

func (*PgSecretStore) ReEncryptUserSecrets

func (s *PgSecretStore) ReEncryptUserSecrets(
	ctx context.Context,
	userID string,
	newKeyVersion int,
	transform func([]byte) ([]byte, error),
	commit func(ctx context.Context) error,
) error

ReEncryptUserSecrets walks every user_secrets row owned by userID inside a single SERIALIZABLE transaction, retrying on serialization failure. After the walk completes the commit closure runs in the same transaction so callers (KeyService.RotateKeyWithPassword) can update related rows (e.g. user_keys.wrapped_dek) atomically. If commit returns non-nil the entire transaction rolls back: secrets stay encrypted with the old DEK and user_keys stays unchanged.

A row-count cap of maxRotateRows is enforced to prevent a malicious or pathological account from holding the rotation transaction open indefinitely.

See Bug 9 in worklog 0085 / 0094.

func (*PgSecretStore) SeedWorkspaceCredentials

func (s *PgSecretStore) SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error

SeedWorkspaceCredentials inserts credential bindings for a new workspace. Idempotent — uses ON CONFLICT DO NOTHING throughout.

  • orgID nil: personal workspace — org auto-apply rules are not applied.
  • orgID non-nil: org workspace — org auto-apply rules and all org credentials are bound.

func (*PgSecretStore) SetBindings

func (s *PgSecretStore) SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error

SetBindings replaces the binding set for workspaceID atomically.

Concurrency: takes a transaction-scoped advisory lock keyed on the workspace ID's hash so two concurrent SetBindings calls for the same workspace serialize. We use pg_try_advisory_xact_lock with a short retry loop rather than pg_advisory_xact_lock (which would block holding a pool connection indefinitely): under a thundering herd of concurrent SetBindings on the same workspace, blocking would exhaust pool connections and stall the entire API. The try-lock fails fast, sleeps a short jittered interval, and retries up to setBindingsLockMaxAttempts times.

The lock auto-releases on commit/rollback so a panicking writer cannot deadlock future writers. Different workspaces hash to different lock numbers and proceed in parallel.

func (*PgSecretStore) UnbindCredentialFromWorkspace

func (s *PgSecretStore) UnbindCredentialFromWorkspace(ctx context.Context, credentialID, workspaceID string) error

UnbindCredentialFromWorkspace removes an EXPLICIT credential binding. Returns ErrAutoBindingProtected if the binding is auto-managed (H-1 fix).

func (*PgSecretStore) UpdateCredential

func (s *PgSecretStore) UpdateCredential(ctx context.Context, ownerType, ownerID, credID string, row *CredentialRow) error

UpdateCredential updates an existing credential scoped to (ownerType, ownerID).

It uses COALESCE for model_allowlist, model_context_limits, and model_output_limits so a nil value means "don't change this column" — the org handler relies on this: a nil modelContextLimits/modelOutputLimits must reach the DB as SQL NULL so COALESCE preserves the existing column value. An empty slice/map is a valid "clear the column" value and is written as-is. Do NOT normalize nil → {} here (it would convert a "don't change" into a "clear all" via COALESCE).

For the admin handler (which allows provider changes and re-encrypts up-front), the caller passes the fully-resolved row with non-nil fields; COALESCE is a no-op there because the caller always supplies concrete values.

updated_at is read back via RETURNING (M-8 fix); the DB trigger sets it to now().

func (*PgSecretStore) UpdateSecret

func (s *PgSecretStore) UpdateSecret(ctx context.Context, secret *UserSecret) error

func (*PgSecretStore) UpsertFreeTierCredential

func (s *PgSecretStore) UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error

UpsertFreeTierCredential atomically upserts the platform free-tier opencode credential and its auto-apply rule in a single transaction.

type PodBootstrapSecretInjector added in v0.2.1

type PodBootstrapSecretInjector interface {
	InjectSecretsForPodBootstrap(ctx context.Context, userID, workspaceID string) ([]byte, error)
}

PodBootstrapSecretInjector is the pod-bootstrap-path variant that attempts a best-effort user-DEK unwrap via KeyService.GetDEKForUser (which walks jwt_sessions rows and the enumerator's retained signing keys) and, on success, decrypts user-DEK bindings alongside server-KEK bindings — as if a JWT-authenticated request had been made.

On DEK-unavailable (no active jwt_sessions, or none unwrappable), implementations MUST degrade to SessionlessSecretInjector semantics: user-DEK bindings audited and skipped, server-KEK-only payload returned. The pod boots with the reduced set and the auto-push flow will still deliver user-DEK when the user next logs in.

Trust model: callers (pod-bootstrap handler) authenticate the workspace SA token via TokenReview and verify the request is on behalf of workspace X. The workspace CRD lists X's owner as the principal whose DEK is fetched. This is not privilege escalation — the pod would receive these same secrets via reload-secrets anyway. See design/0045_2026-07-06_boot-time-user-dek-delivery.md § Threat model.

Implementations: *SecretService.

type PostgresSecretProvider

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

PostgresSecretProvider implements SecretProvider using the KeyService and SecretStore.

func NewPostgresSecretProvider

func NewPostgresSecretProvider(keys *KeyService, store SecretStore) *PostgresSecretProvider

NewPostgresSecretProvider creates a new PostgresSecretProvider.

func (*PostgresSecretProvider) DEKAvailable

func (p *PostgresSecretProvider) DEKAvailable(ctx context.Context, owner SecretOwner) bool

func (*PostgresSecretProvider) Decrypt

func (p *PostgresSecretProvider) Decrypt(ctx context.Context, owner SecretOwner, ciphertext []byte, keyVersion int) ([]byte, error)

func (*PostgresSecretProvider) Encrypt

func (p *PostgresSecretProvider) Encrypt(ctx context.Context, owner SecretOwner, plaintext []byte) ([]byte, int, error)

func (*PostgresSecretProvider) RotateKey

func (p *PostgresSecretProvider) RotateKey(ctx context.Context, owner SecretOwner) (int, error)

type RedisDEKCache

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

RedisDEKCache implements DEKCache using Redis. If a master key is provided, DEKs are encrypted before storage in Redis.

func NewRedisDEKCache

func NewRedisDEKCache(client *redis.Client, masterKey ...[]byte) *RedisDEKCache

NewRedisDEKCache creates a new Redis-backed DEK cache. masterKey is optional — if nil or empty, DEKs are stored as plain hex.

func (*RedisDEKCache) CacheDEK

func (c *RedisDEKCache) CacheDEK(ctx context.Context, sessionID string, dek []byte, ttl time.Duration) error

func (*RedisDEKCache) EvictDEK

func (c *RedisDEKCache) EvictDEK(ctx context.Context, sessionID string) error

func (*RedisDEKCache) GetDEK

func (c *RedisDEKCache) GetDEK(ctx context.Context, sessionID string) ([]byte, error)

type RootKeyProvider

type RootKeyProvider interface {
	Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)
	Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)
}

type RotationCoordinator

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

RotationCoordinator re-wraps KEK-protected rows from old providers to new providers. It is table-agnostic: the caller provides the store + provider sets.

func NewRotationCoordinator

func NewRotationCoordinator(store RotationStore, oldProviders, newProviders map[string]RootKeyProvider) *RotationCoordinator

NewRotationCoordinator constructs a coordinator with the old (source) and new (target) provider sets. Each map is keyed by purpose string.

func (*RotationCoordinator) RotateAll

func (c *RotationCoordinator) RotateAll(ctx context.Context, targetVersion int, dryRun bool) (map[string]KEKRotationResult, error)

RotateAll rotates all three tables sequentially. The Redis DEK cache is flushed after all tables complete successfully.

func (*RotationCoordinator) RotateTable

func (c *RotationCoordinator) RotateTable(ctx context.Context, table, resumeFromID string, targetVersion int, dryRun bool) (KEKRotationResult, error)

RotateTable rotates all rows in the given table that are below targetVersion. If dryRun is true, no writes occur — only counts are reported. resumeFromID allows resuming from a specific row ID after an interrupted run.

type RotationResult

type RotationResult struct {
	NewKeyVersion     int
	NewRecoveryKeyHex string
}

RotationResult is what RotateKeyWithPassword returns. NewKeyVersion is the bumped key_version; NewRecoveryKeyHex is a freshly-issued recovery key (the previous one wraps the now-discarded old DEK and is invalid after rotation). Callers MUST surface NewRecoveryKeyHex to the user once — the API does not store it anywhere recoverable.

type RotationRow

type RotationRow struct {
	ID         string
	Table      string // "provider_credentials", "api_keys", "org_sso_configs"
	OwnerType  string // for provider_credentials: "admin" or "org"; "" for other tables
	Ciphertext []byte
	KeyVersion int
}

RotationRow is a generic row from any KEK-protected table. The coordinator re-wraps Ciphertext from oldKeyVersion to newKeyVersion.

type RotationStore

type RotationStore interface {
	// ListRotationRows returns rows from the given table whose key_version is
	// below targetVersion, ordered by ID ASC, starting after resumeFromID
	// (empty = from the beginning). limit caps the batch size (0 = unlimited).
	ListRotationRows(ctx context.Context, table, resumeFromID string, targetVersion, limit int) ([]RotationRow, error)

	// UpdateRotationRow re-wraps a single row: writes newCiphertext and
	// newKeyVersion atomically. Each call is its own transaction.
	UpdateRotationRow(ctx context.Context, table, rowID string, newCiphertext []byte, newKeyVersion int) error

	// FlushDEKCache flushes the Redis DEK cache so stale DEKs (wrapped under
	// the old KEK) are evicted.
	FlushDEKCache(ctx context.Context) error
}

RotationStore abstracts the three KEK-protected tables for the rotation CLI. Each method returns rows in a consistent order (by ID ASC) so resume-from- cursor works deterministically.

type SealedKeyProvider

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

SealedKeyProvider holds the unsealed root key in process memory. It defends against attackers who can read the sealed file or the node disk but NOT the passphrase: the on-disk file is Argon2id-wrapped and is useless without the passphrase. It does NOT defend against process-level compromise of the API pod — once the key is unsealed at boot it lives in memory, and an attacker who can run code in the pod can call Decrypt exactly as the application does. See pkg/secrets/README.md for the full threat model.

US-50.4 multi-key support (NewStaticKeyProviderMultiVersion) is NOT mirrored here yet. The sealed provider is constructed once at boot from a single sealed file; multi-file rotation-window support for the sealed path will be added alongside US-50.5 (rotate-kek CLI) when the rotation workflow is exercised end-to-end. The StaticKeyProvider covers the default Helm path.

func NewSealedKeyProvider

func NewSealedKeyProvider(sealedKeyPath, passphrasePath string) (*SealedKeyProvider, error)

func (*SealedKeyProvider) Decrypt

func (p *SealedKeyProvider) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)

func (*SealedKeyProvider) Encrypt

func (p *SealedKeyProvider) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)

type SecretBinding

type SecretBinding struct {
	SecretID    string    `json:"secretId"`
	WorkspaceID string    `json:"workspaceId"`
	CreatedAt   time.Time `json:"createdAt"`
}

SecretBinding represents a secret-to-workspace binding.

type SecretInjector

type SecretInjector interface {
	InjectSecrets(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, workspaceID string) ([]byte, error)
}

SecretInjector decrypts and serializes the secrets bound to a workspace, using the calling user's session DEK to decrypt user-owned credentials and user_secrets entries (ssh-key, env-secret, secret-file, etc.).

Used by handlers authenticated via JWT — the bind-time live-push and the explicit POST /api/v1/workspaces/:id/reload-secrets endpoint. The sessionID is mandatory for these handlers; without it user-DEK content cannot be decrypted.

Implementations: *SecretService.

type SecretOwner

type SecretOwner struct {
	ID   string
	Type OwnerType
}

SecretOwner identifies the owner of a secret (user or org).

type SecretProvider

type SecretProvider interface {
	// Encrypt encrypts plaintext with the owner's current DEK.
	Encrypt(ctx context.Context, owner SecretOwner, plaintext []byte) (ciphertext []byte, keyVersion int, err error)

	// Decrypt decrypts ciphertext using the appropriate DEK version.
	Decrypt(ctx context.Context, owner SecretOwner, ciphertext []byte, keyVersion int) (plaintext []byte, err error)

	// RotateKey generates a new DEK for the owner. Old DEK retained for lazy migration.
	RotateKey(ctx context.Context, owner SecretOwner) (newKeyVersion int, err error)

	// DEKAvailable returns true if the owner's DEK is currently cached (active session).
	DEKAvailable(ctx context.Context, owner SecretOwner) bool
}

SecretProvider defines the encryption/decryption interface for user secrets. V1: PostgresSecretProvider (HKDF + AES-GCM + session cache) Future: VaultSecretProvider, HSMSecretProvider

type SecretResponse

type SecretResponse struct {
	ID            string          `json:"id"`
	Name          string          `json:"name"`
	Type          SecretType      `json:"type"`
	Metadata      json.RawMessage `json:"metadata"`
	GlobalDefault bool            `json:"globalDefault"`
	CreatedAt     time.Time       `json:"createdAt"`
	UpdatedAt     time.Time       `json:"updatedAt"`
}

SecretResponse is the API response for a secret (never includes value).

type SecretService

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

SecretService provides encrypted secret CRUD operations.

func NewSecretService

func NewSecretService(keys *KeyService, store SecretStore) *SecretService

NewSecretService creates a new SecretService.

As a side-effect we register the SecretStore on the KeyService so RotateKeyWithPassword can re-encrypt secrets in-place (Bug 9 in worklog 0085). The two services share a store anyway; this just makes the linkage explicit at construction time.

func (*SecretService) AddBindings

func (s *SecretService) AddBindings(ctx context.Context, userID, workspaceID string, secretIDs []string) (BindingsMutationResult, error)

AddBindings adds secretIDs to a workspace's binding set without removing any existing bindings. The store-level implementation takes a workspace-scoped advisory lock so concurrent SetBindings / AddBindings calls cannot lose updates (worklog 0094 pass-2 finding O1). Each secret's ownership is verified before the binding is recorded; an unowned secret produces ErrSecretNotFound.

Used by SetWorkspaceEnv to merge newly-created env-secrets into the workspace bindings without the Get-then-Set window the previous implementation suffered from. Workspace ownership is enforced by WorkspaceAccessMiddleware on PUT /:id/env (design 0041 D5).

func (*SecretService) CreateSecret

func (s *SecretService) CreateSecret(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, req CreateSecretRequest) (*SecretResponse, error)

CreateSecret encrypts and stores a new secret.

func (*SecretService) DecryptSecretValue

func (s *SecretService) DecryptSecretValue(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, secretID string) ([]byte, error)

DecryptSecretValue decrypts a secret's value (used for pod injection).

func (*SecretService) DeleteSecret

func (s *SecretService) DeleteSecret(ctx context.Context, userID, secretID string) error

DeleteSecret removes a secret and its bindings.

func (*SecretService) GetBindings

func (s *SecretService) GetBindings(ctx context.Context, userID, workspaceID string) (*BindingsResponse, error)

GetBindings returns secrets bound to a workspace. Workspace ownership is enforced by WorkspaceAccessMiddleware on GET /:id/bindings (design 0041 D5); the service trusts that decision and does not re-fetch the workspace.

func (*SecretService) GetBindingsForSecret

func (s *SecretService) GetBindingsForSecret(ctx context.Context, userID, secretID string) ([]string, error)

GetBindingsForSecret returns workspace IDs that a secret is bound to.

Ownership-failure modes (secret not found, secret owned by someone else) are conflated to a uniform empty result so the response shape does not leak existence cross-tenant. Genuine system errors (DB outage on the lookup) propagate so the handler can return 5xx instead of a misleading empty 200.

func (*SecretService) GetSecret

func (s *SecretService) GetSecret(ctx context.Context, userID, secretID string) (*SecretResponse, error)

GetSecret returns secret metadata (never the value).

func (*SecretService) GetSecretByName

func (s *SecretService) GetSecretByName(ctx context.Context, userID, name string) (*SecretResponse, error)

GetSecretByName returns secret metadata by name (never the value).

func (*SecretService) InjectSecrets

func (s *SecretService) InjectSecrets(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, workspaceID string) ([]byte, error)

InjectSecrets implements SecretInjector. See interface godoc.

Workspace ownership is enforced by WorkspaceAccessMiddleware on POST /:id/reload-secrets (design 0041 D5) and is inherently true for the bind-time push path inside the SecretsHandler (the caller is the workspace owner). This method does not re-check ownership — the HTTP layer must.

ARCHITECTURAL NOTE — credential-class delivery semantics:

Admin (owner_type='admin') and org (owner_type='org') credentials use server-side KEKs derived in pkg/secrets/root_key.go. They can be decrypted regardless of session, and are always included.

User credentials (owner_type='user') and user_secrets entries (ssh-key, env-secret, etc.) are encrypted with the user's DEK, which requires an active authenticated session. When sessionID identifies a session without a cached DEK (expired, evicted, or never unlocked), the affected entries are skipped with an audit event and the workspace falls back to lower-priority server-KEK entries.

Callers without any session at all (init container, API-key auth) MUST use SessionlessSecretInjector instead of passing an empty sessionID here — that path was previously the source of bug class "buildNonLLMSecrets propagates GetDEK error" (this worklog, 2026-06-24 production incident).

func (*SecretService) InjectSecretsForPodBootstrap added in v0.2.1

func (s *SecretService) InjectSecretsForPodBootstrap(ctx context.Context, userID, workspaceID string) ([]byte, error)

InjectSecretsForPodBootstrap implements PodBootstrapSecretInjector. See interface godoc and design/0045_2026-07-06_boot-time-user-dek-delivery.md.

Attempts a best-effort user-DEK unwrap via KeyService.GetDEKForUser. On success, delegates to InjectSecrets so user-DEK bindings decrypt through the normal (dek, jti) → decryptBinding path. GetDEKForUser writes the unwrapped DEK back to Redis under the returned jti, so the downstream GetDEK(jti) call in decryptBinding hits the cache — one unwrap per request, not per binding.

On DEK-unavailable (no active jwt_sessions row for this user, none unwrappable with retained signing keys, or KeyService not wired at all), falls back to InjectSessionlessSecrets. The pod boots with server-KEK-only secrets; the auto-push flow (secretautopush) will deliver user-DEK secrets when the user next logs in.

Errors from GetDEKForUser other than ErrDEKUnavailable are treated as "unavailable" and degrade the same way: a transient DB failure at pod boot must not fail the boot; auto-push will retry once the API recovers. The specific error is not logged here because GetDEKForUser's callers (secretautopush.run, this method) both treat it uniformly — the KeyService's own logs already record the failure.

func (*SecretService) InjectSessionlessSecrets

func (s *SecretService) InjectSessionlessSecrets(ctx context.Context, userID, workspaceID string) ([]byte, error)

InjectSessionlessSecrets implements SessionlessSecretInjector. See interface godoc.

Returns server-KEK-decryptable credentials only. User-DEK bindings are not just skipped — they are audited via "secret_skipped_no_session" events so operators have signal that a workspace's user-owned content is awaiting a JWT-authenticated delivery via reload-secrets. Without auditing, an operator inspecting "why is the agent not seeing my SSH key after a pod restart" has no breadcrumb at all.

func (*SecretService) ListSecrets

func (s *SecretService) ListSecrets(ctx context.Context, userID string) ([]*SecretResponse, error)

ListSecrets returns all secret metadata for a user (never values).

func (*SecretService) QueryAudit

func (s *SecretService) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)

QueryAudit returns audit log entries for the current user.

func (*SecretService) SeedGlobalDefaultSecrets

func (s *SecretService) SeedGlobalDefaultSecrets(ctx context.Context, workspaceID, userID string) error

SeedGlobalDefaultSecrets binds all secrets with global_default=true owned by userID to the given workspace. Called by the workspace service on workspace creation as a best-effort operation (failure is logged but does not roll back the workspace). Uses the service-level AddBindings so each auto-bind is recorded in the audit log (matching every user-initiated bind path) and is idempotent if called more than once for the same workspace (ON CONFLICT DO NOTHING at the store layer).

func (*SecretService) SetAdminProvider

func (s *SecretService) SetAdminProvider(p RootKeyProvider)

SetAdminProvider installs the RootKeyProvider for admin (owner_type='admin') provider credentials. When non-nil, the injector methods (InjectSecrets and InjectSessionlessSecrets) decrypt admin bindings through it; when nil, admin bindings are skipped with an audit event.

func (*SecretService) SetBindings

func (s *SecretService) SetBindings(ctx context.Context, userID, workspaceID string, secretIDs []string) (BindingsMutationResult, error)

SetBindings sets which secrets are bound to a workspace. The caller must own every secret being bound; an unowned secret produces ErrSecretNotFound (mapped to 404 by the handler). Workspace ownership itself is enforced by WorkspaceAccessMiddleware on PUT /:id/bindings (design 0041 D5) — the service trusts that decision so it can also be called from background paths where the caller is implicitly authorized (e.g. workspace.Service.refreshEphemeralSecrets).

func (*SecretService) SetOrgProvider

func (s *SecretService) SetOrgProvider(p RootKeyProvider)

SetOrgProvider installs the RootKeyProvider for org (owner_type='org') provider credentials. When non-nil, the injector methods (InjectSecrets and InjectSessionlessSecrets) decrypt org bindings through it; when nil, org bindings are skipped with an audit event.

func (*SecretService) UpdateSecret

func (s *SecretService) UpdateSecret(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, secretID string, req UpdateSecretRequest) error

UpdateSecret re-encrypts and updates a secret's value.

type SecretStore

type SecretStore interface {
	CreateSecret(ctx context.Context, secret *UserSecret) error
	GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)
	GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)
	ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
	// ListGlobalDefaultSecrets returns all secrets owned by userID that have
	// global_default=true. Used when seeding bindings on workspace creation.
	ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
	UpdateSecret(ctx context.Context, secret *UserSecret) error
	DeleteSecret(ctx context.Context, userID, secretID string) error

	// ReEncryptUserSecrets re-encrypts every row owned by userID in a
	// single atomic operation. The transform closure receives the old
	// ciphertext for a row and must return the new ciphertext (decrypted
	// with the old DEK and re-encrypted with the new one). After all
	// rows are re-encrypted but before the transaction commits, the
	// commit closure is invoked with the same tx so the caller can
	// piggyback related updates (e.g. user_keys.wrapped_dek) into the
	// same atomic unit; if commit returns non-nil the entire transaction
	// rolls back. Implementations MUST run all of this in a single
	// SERIALIZABLE transaction with retry on serialization failure.
	//
	// A partial state would leave rows decryptable only by a key the
	// system has discarded — the failure mode of Bug 9 in worklog 0085.
	ReEncryptUserSecrets(
		ctx context.Context,
		userID string,
		newKeyVersion int,
		transform func(oldCiphertext []byte) (newCiphertext []byte, err error),
		commit func(ctx context.Context) error,
	) error

	// Bindings
	SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error
	// AddBindings atomically adds secretIDs to a workspace's binding
	// set without removing any existing bindings. Implementations
	// MUST take the same workspace-scoped advisory lock as
	// SetBindings so concurrent Add+Set callers serialize. Existing
	// bindings to the same secret are silently ignored
	// (INSERT ... ON CONFLICT DO NOTHING semantics).
	//
	// Used by SetWorkspaceEnv to add new env-secrets without racing
	// on a Get-then-Set window — see worklog 0094 pass-2 finding O1.
	AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error
	GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)
	GetBindingsForSecret(ctx context.Context, secretID string) ([]string, error)

	// Audit
	LogAudit(ctx context.Context, entry *AuditEntry) error
	QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
}

SecretStore abstracts database operations for user secrets.

type SecretType

type SecretType string

SecretType defines the type of secret.

const (
	// APIKeySunsetDate is the fixed date on which new api-key secrets
	// become uncreatable (US-44.9). Six months after the Epic 44 ship
	// date. Existing api-key secrets remain functional after this date;
	// only creation is blocked by the CreateSecret gate.
	APIKeySunsetDate = "2026-12-19"

	// SecretTypeAPIKey is for generic API-key secrets (legacy).
	// New code should use SecretTypeLLMProvider for structured provider
	// credentials. Kept for backward compatibility with existing secrets.
	SecretTypeAPIKey SecretType = "api-key"
	// SecretTypeLLMProvider is for structured LLM provider credentials
	// (Anthropic, OpenAI, etc.). Each secret holds one provider with
	// its API key, optional base URL, model visibility allowlist, and
	// default model selection. Multiple llm-provider secrets bound to
	// the same workspace are merged by the agent's FormatProviderConfig.
	SecretTypeLLMProvider   SecretType = "llm-provider"
	SecretTypeSSHKey        SecretType = "ssh-key"
	SecretTypeGitCredential SecretType = "git-credential"
	SecretTypeSecretFile    SecretType = "secret-file"
	SecretTypeEnvSecret     SecretType = "env-secret"
)

func ValidSecretTypesList

func ValidSecretTypesList() []SecretType

ValidSecretTypesList returns the canonical list of valid secret types, in stable order. Used to format the error message returned when a caller submits an invalid type, so the response is self-documenting.

type SessionlessSecretInjector

type SessionlessSecretInjector interface {
	InjectSessionlessSecrets(ctx context.Context, userID, workspaceID string) ([]byte, error)
}

SessionlessSecretInjector returns the subset of workspace secrets that can be decrypted without a user session — admin and org provider credentials, encrypted with server-KEK material (US-50.2 RootKeyProvider).

Used by callers without a user session:

  • Pod bootstrap (Epic 35 US-35.3): the init container POSTs the API with a projected SA token; there is no JWT and no DEK in flight. NOTE: pod-bootstrap now prefers PodBootstrapSecretInjector when available (design/0045). SessionlessSecretInjector remains the fallback contract for callers that cannot request user-DEK on the caller's behalf.

  • API-key authenticated handlers (e.g. SDK calls without a JWT): the handler cannot decrypt user-DEK content, so the SessionlessSecretInjector gives it the server-KEK subset only. The user-DEK content is delivered later by a JWT-authenticated reload (the existing two-phase pattern documented in commit 4b48a4e7).

User-owned credentials and user_secrets entries are intentionally omitted; they are emitted as audit events ("secret_skipped_no_session") so operators have observability into what was deferred. Implementations MUST audit every skipped binding to preserve the existing observability contract documented in pkg/secrets/secret_service.go (M-5 fix).

Implementations: *SecretService.

type SetBindingsRequest

type SetBindingsRequest struct {
	SecretIDs []string `json:"secretIds" binding:"required"`
}

SetBindingsRequest is the API request for setting workspace bindings.

type SigningKeyEnumerator

type SigningKeyEnumerator interface {
	EachSigningKey(fn func(key []byte) bool)
}

SigningKeyEnumerator exposes the API's active JWT signing keys to callers that need to unwrap a durable DEK on behalf of a user in a background context (workspace watcher, controller-triggered auto- push, etc.). Implemented by auth.Service via a wrapper that iterates s.jwtSecret followed by s.jwtPreviousSecrets.

The callback contract: `fn` returns TRUE to continue iteration or FALSE to stop (typical: stop after first successful unwrap). Bytes passed to `fn` MUST NOT be retained by the callback — implementations may reuse a single backing buffer, or copy from internal state and zero on return. Callers that need to retain a key past the callback call must copy.

type StaticKeyProvider

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

StaticKeyProvider holds one or more versioned keys. Encrypt always uses the highest-version (active) key; Decrypt tries each key newest-to-oldest and returns the first success. This enables zero-downtime KEK rotation (US-50.4, design D4): during the transition window the provider holds both old and new keys so ciphertexts encrypted under either version decrypt correctly.

func NewStaticKeyProvider

func NewStaticKeyProvider(key []byte) (*StaticKeyProvider, error)

NewStaticKeyProvider constructs a single-key provider at version 1. This is the backward-compatible constructor used everywhere except the rotation window.

func NewStaticKeyProviderMultiVersion

func NewStaticKeyProviderMultiVersion(activeVersion int, keyByVersion map[int][]byte) (*StaticKeyProvider, error)

NewStaticKeyProviderMultiVersion constructs a multi-key provider for the rotation transition window (US-50.4). activeVersion is the highest version (the one Encrypt uses); keyByVersion maps every version to its key material. At least one entry at activeVersion must exist. Entries are stored sorted by version descending so Decrypt tries the newest first.

func (*StaticKeyProvider) ActiveVersion

func (p *StaticKeyProvider) ActiveVersion() int

ActiveVersion returns the highest version the provider can encrypt with (US-50.3 uses this to populate key_version columns on encrypt).

func (*StaticKeyProvider) Decrypt

func (p *StaticKeyProvider) Decrypt(ctx context.Context, ciphertext []byte) ([]byte, error)

func (*StaticKeyProvider) Encrypt

func (p *StaticKeyProvider) Encrypt(ctx context.Context, plaintext []byte) ([]byte, error)

type UpdateSecretRequest

type UpdateSecretRequest struct {
	Value    string          `json:"value" binding:"required"`
	Metadata json.RawMessage `json:"metadata,omitempty"`
	// GlobalDefault, when non-nil, updates whether this secret is automatically
	// bound to newly-created workspaces. Nil means "leave unchanged".
	GlobalDefault *bool `json:"globalDefault,omitempty"`
}

UpdateSecretRequest is the API request for updating a secret value.

type UserKeyRecord

type UserKeyRecord struct {
	UserID             string
	KeyVersion         int
	WrappedDEK         []byte
	WrappedDEKRecovery []byte // nil if user opted out
	Salt               []byte
	RecoverySalt       []byte // nil if user opted out
	CreatedAt          time.Time
	RotatedAt          *time.Time
}

UserKeyRecord represents a row in the user_keys table.

type UserSecret

type UserSecret struct {
	ID            string          `json:"id"`
	UserID        string          `json:"userId"`
	Name          string          `json:"name"`
	Type          SecretType      `json:"type"`
	Ciphertext    []byte          `json:"-"` // never exposed via API
	KeyVersion    int             `json:"keyVersion"`
	Metadata      json.RawMessage `json:"metadata"`
	GlobalDefault bool            `json:"globalDefault"`
	CreatedAt     time.Time       `json:"createdAt"`
	UpdatedAt     time.Time       `json:"updatedAt"`
}

UserSecret represents an encrypted secret record.

type VersionedProvider

type VersionedProvider interface {
	ActiveVersion() int
}

VersionedProvider is implemented by providers that expose an active key version (US-50.3/50.4). Callers that need the version for key_version column writes assert this interface on the concrete provider — it is intentionally NOT on RootKeyProvider so a future external provider (Vault Transit, which handles versioning server-side) doesn't need to implement it.

Jump to

Keyboard shortcuts

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