keyrewrap

package
v0.34.13 Latest Latest
Warning

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

Go to latest
Published: Oct 1, 2026 License: AGPL-3.0 Imports: 11 Imported by: 0

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

View Source
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.

View Source
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

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).

func (*Service) Run

func (s *Service) Run(ctx context.Context)

Run blocks until ctx is canceled: one immediate startup pass (a mike-class row heals without waiting a full period), then one pass per period. The kill switch returns before any walk.

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.

Jump to

Keyboard shortcuts

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