Documentation
¶
Overview ¶
Package keyrewrap is the US-70.4 login-independent re-wrap reconciler (design 0052 §4.5 item 5, issue #1208).
The 2026-08-28 v0.25.1 incident: a June-era user_keys.wrapped_dek row the login-gated migration never touched → GetDEKServerSide unwrap failure → silent sessionless degrade. A login-gated migration cannot converge rows whose owners never return. This service walks user_keys on a period and heals those rows without any login:
- Row unwrap attempt with the CURRENT master provider. Success → healthy, skip.
- Failure → recovery, in order: (a) the warm-cache DEK walk (KeyService.GetCachedDEKForUser — the K1 Redis entries reachable through the jwt_sessions index, while pre-US-70.5 rows exist); (b) nothing else. No source → outcome unwrappable_no_source, surfaced via metric + audit + alert (never silently labeled).
- Source agreement (W9): a recovered DEK must decrypt at least one existing secret for that user before any re-wrap — defense against a corrupt cache poisoning the durable row. A user with ZERO secrets surfaces unwrappable:no_secret_to_verify and is NEVER healed unverified (W11).
- Verify-after-write: the new wrap must round-trip through the provider BEFORE any write; a non-round-tripping wrap is never committed (mirrors healLegacyDEK's discipline).
- CAS write: the heal applies only if the row still byte-equals the wrap that was listed; a concurrent legitimate rotation wins, the reconciler backs off (outcome cas_lost — no retry storm).
- Retained wrap (W10): the previous wrap is preserved time-boxed (30 days) AS CIPHERTEXT re-wrapped under the CURRENT KEK — retention adds zero plaintext at rest and a bad heal stays reversible with current keys. Rows past the deadline have the previous columns NULLed as part of every pass.
- Staged rollout + kill switch: batches walk oldest-first (oldest = highest risk — they predate the most rotation windows); ≥3 verify failures in one pass halt further batches this pass and raise llmsafespaces_key_rewrap_halted; the LLMSAFESPACES_KEY_REWRAP_DISABLED env var is a full kill switch.
Delivery coupling: NONE by design. Degraded-DEK workspaces converge via US-70.3's reconcile loop after the row heals — the next batch build/pull decrypts cleanly; this service never notifies pods.
Config is env-only (no values.yaml surface): period (LLMSAFESPACES_KEY_REWRAP_PERIOD), batch size (LLMSAFESPACES_KEY_REWRAP_BATCH_SIZE), kill switch (LLMSAFESPACES_KEY_REWRAP_DISABLED).
Index ¶
Constants ¶
const ( DefaultPeriod = 10 * time.Minute DefaultBatchSize = 200 )
Defaults. Period 10m: a mike-class row converges within one period with the owner offline (AC). Batch 200: bounds per-pass decrypt work; heals move rows to the END of the updated_at walk order, so a pass terminates after ceil(N/batch)+1 windows. Retention 30d per W10.
const ( EnvPeriod = "LLMSAFESPACES_KEY_REWRAP_PERIOD" EnvBatchSize = "LLMSAFESPACES_KEY_REWRAP_BATCH_SIZE" EnvDisabled = "LLMSAFESPACES_KEY_REWRAP_DISABLED" )
Env knobs (env-only config; no values.yaml keys).
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
Period time.Duration
BatchSize int
Disabled bool
// HaltOnVerifyFailures is the per-pass verify-failure count at which
// further batches are halted (I11 blast-radius control).
HaltOnVerifyFailures int
}
Config is the reconciler's tunable surface. Construct via ConfigFromEnv in production; tests build it directly.
func ConfigFromEnv ¶
func ConfigFromEnv() Config
ConfigFromEnv resolves the env-only configuration. Invalid values fall back to defaults (the uploads env convention) — EXCEPT the kill switch, which fails closed: an unparseable non-empty DISABLED value disables the reconciler (a typo must not silently re-enable writes).
type DEKRecoverer ¶
type DEKRecoverer interface {
GetCachedDEKForUser(ctx context.Context, userID string) (dek []byte, jti string, err error)
}
DEKRecoverer is the recovery-source seam — satisfied by *secrets.KeyService (GetCachedDEKForUser: the warm Redis session cache reachable through the jwt_sessions index; it never re-derives KEKs and never unwraps — US-70.5).
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the re-wrap reconciler. Construct with New; run with Run (blocks until ctx cancellation — the jwt_sessions janitor lifecycle pattern: started as a background goroutine in app.Run, stopped by the app's root-context cancel during Shutdown).
func New ¶
func New( store secrets.ReconcileKeyStore, recoverer DEKRecoverer, provider secrets.RootKeyProvider, secretLister UserSecretLister, audit secrets.AuditWriter, logger pkginterfaces.LoggerInterface, cfg Config, ) *Service
New constructs the reconciler. store, recoverer, and provider are required; a nil secret lister makes every heal surface unwrappable_no_secret (fail-closed W11); nil audit/logger degrade to no-op observability (production always wires both).
type UserSecretLister ¶
type UserSecretLister interface {
ListSecrets(ctx context.Context, userID string) ([]*secrets.UserSecret, error)
}
UserSecretLister is the cheapest existing per-user secret listing — satisfied by secrets.SecretStore (PgSecretStore). W9 agreement needs one ciphertext the recovered DEK decrypts; user secret cardinalities are tens, so the full listing is the right cost/complexity trade.