secrets

package
v0.2.0-beta.18 Latest Latest
Warning

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

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

Documentation

Overview

Package secrets implements envelope encryption: per-app data encryption keys, wrapped by a master key held only by the control plane, using filippo.io/age for the crypto primitives.

masterkey.go, dek.go, and value.go are the primitives layer: MasterKey, DEK wrap/unwrap, and value encrypt/decrypt, standalone and fully tested, knowing nothing about storage. manager.go (the secret-storage follow-up, landed 2026-08-12) is the integration layer: Manager combines a MasterKey with internal/store's new service_secrets/ service_secret_values tables to generate-or-reuse a per-app DEK, encrypt and persist a value, and decrypt one back. Callers: internal/deploy.Pipeline checks whether a { secret: true } var has a value before deploying (never the value itself), and internal/reconcile/application.Controller decrypts a value only immediately before docker.Runtime.Create, never persisting it, which is exactly the sequencing envelope encryption is meant to guarantee.

Index

Constants

View Source
const ScopeServiceSecret = "service_secret"

ScopeServiceSecret is the Binding scope of every value Manager stores.

Variables

View Source
var (
	// ErrBindingMismatch means a ciphertext decrypted fine but was sealed
	// for a different slot than the one it was read from.
	ErrBindingMismatch = errors.New("secrets: ciphertext is bound to a different slot")
	// ErrMalformedEnvelope means a bound ciphertext authenticated but its
	// framing did not parse.
	ErrMalformedEnvelope = errors.New("secrets: malformed secret envelope")
	// ErrInvalidBinding means a Binding has an empty or oversized field.
	ErrInvalidBinding = errors.New("secrets: invalid binding")
	// ErrValueTooLarge means a plaintext exceeds maxPlaintextBytes.
	ErrValueTooLarge = errors.New("secrets: value too large")
)
View Source
var ErrLegacyRejected = errors.New("secrets: legacy unbound ciphertext rejected")

ErrLegacyRejected is returned by Resolve for a legacy unbound ciphertext when the Manager was built WithRequireBound(true).

View Source
var ErrSecretLocked = errors.New("secrets: value is locked")

ErrSecretLocked is returned by SetValueGuarded when overwriting an existing, locked value without overwriteLocked=true; callers surface it as a 409.

View Source
var ErrValueNotFound = errors.New("secrets: value not found")

ErrValueNotFound means no secret value has been set for a given (service, env key) pair, whether the DEK or the ciphertext is missing.

Functions

func DecryptValue

func DecryptValue(dek []byte, b Binding, ciphertext []byte) (plaintext string, legacy bool, err error)

DecryptValue reverses EncryptValue, failing with ErrBindingMismatch when the ciphertext was sealed for a different Binding. legacy is true for a ciphertext written before bindings existed, which carries no slot to check.

func EncryptValue

func EncryptValue(dek []byte, b Binding, plaintext string) ([]byte, error)

EncryptValue encrypts plaintext under dek (a raw, unwrapped DEK) with AES-256-GCM, sealing b inside so the result only decrypts back through DecryptValue with the same Binding.

func PersistMasterKeyFile

func PersistMasterKeyFile(path, serialized string) error

PersistMasterKeyFile atomically writes serialized (a MasterKey's String() output) to path: written to a temp file in the same directory first, then renamed into place, so a crash mid-write can never leave a truncated, unloadable key file behind.

func RotateStoredDEKs

func RotateStoredDEKs(ctx context.Context, s Store, oldKey, newKey *MasterKey) error

RotateStoredDEKs re-wraps every DEK s holds from oldKey to newKey, via Store.RotateServiceDEKs's single transaction: if any DEK fails to unwrap under oldKey (wrong key supplied, corrupt data), the callback returns an error, the transaction rolls back, and no row changes. This is the low-level primitive Manager.RotateMasterKey builds on; it takes both keys explicitly (rather than reading Manager state) so it can be tested in isolation, including the wrong-old-key case.

Types

type Binding added in v0.2.0

type Binding struct {
	Scope string
	Owner string
	Key   string
}

Binding is the storage slot a ciphertext belongs to. It is sealed inside the ciphertext and checked on every decrypt, so a ciphertext copied into a different slot fails closed instead of decrypting.

type BindingStatus added in v0.2.0

type BindingStatus struct {
	Total  int
	Bound  int
	Legacy int
}

BindingStatus counts stored values by envelope format.

type Manager

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

Manager combines a MasterKey with a Store to provide per-app envelope encryption end to end, binding every value to its (service, key) slot.

func NewManager

func NewManager(store Store, mk *MasterKey, opts ...ManagerOption) *Manager

NewManager builds a Manager around mk, the control plane's in-memory master key.

func (*Manager) BindingStatus added in v0.2.0

func (m *Manager) BindingStatus(ctx context.Context) (BindingStatus, error)

BindingStatus reports how many stored values still use the legacy unbound format, from a prefix count that never decrypts anything.

func (*Manager) DeleteAll

func (m *Manager) DeleteAll(ctx context.Context, serviceName string) error

DeleteAll permanently removes every value and the wrapped DEK for serviceName, so nothing set under it can ever be decrypted again.

func (*Manager) Exists

func (m *Manager) Exists(ctx context.Context, serviceName, envKey string) (bool, error)

Exists reports whether a value has been set for (serviceName, envKey), without decrypting it.

func (*Manager) ExistsForServices added in v0.2.0

func (m *Manager) ExistsForServices(ctx context.Context, serviceNames []string, envKey string) (map[string]bool, error)

ExistsForServices reports, for every name in serviceNames, whether a value has been set for (name, envKey), without decrypting it, via one batched store call instead of an Exists call per service.

func (*Manager) GetMasterKeyRotatedAt

func (m *Manager) GetMasterKeyRotatedAt(ctx context.Context) (time.Time, bool, error)

GetMasterKeyRotatedAt returns the last time RotateMasterKey succeeded, or ok=false if it never has.

func (*Manager) ListKeys

func (m *Manager) ListKeys(ctx context.Context, serviceName string) ([]store.SecretKeyInfo, error)

ListKeys returns every secret key set for serviceName with its locked state, never a value.

func (*Manager) Rebind added in v0.2.0

func (m *Manager) Rebind(ctx context.Context) (RebindResult, error)

Rebind re-encrypts every legacy value in place with its slot binding. Each row is swapped on its own, compare-and-swap, so an interrupted run keeps its progress and a rerun picks up where it stopped. Bound rows are verified and left alone, which makes it idempotent. A storage error aborts the run; a row that fails to decrypt is reported and skipped.

func (*Manager) Resolve

func (m *Manager) Resolve(ctx context.Context, serviceName, envKey string) (string, error)

Resolve decrypts and returns the plaintext value for (serviceName, envKey), or ErrValueNotFound if none was ever set. A ciphertext bound to any other slot fails with ErrBindingMismatch. Callers must never persist what this returns.

func (*Manager) RotateMasterKey

func (m *Manager) RotateMasterKey(ctx context.Context, newMasterKey string) (time.Time, error)

RotateMasterKey re-wraps every stored DEK from the active master key to newMasterKey (a serialized age identity), then swaps the active key. On any failure nothing changes: the DB rotation is one transaction and the in-memory key is only swapped after it commits.

func (*Manager) SetLocked

func (m *Manager) SetLocked(ctx context.Context, serviceName, envKey string, locked bool) error

SetLocked toggles (serviceName, envKey)'s locked flag, reversible in either direction. Returns store.ErrSecretValueNotFound if no value has been set for that key yet.

func (*Manager) SetValue

func (m *Manager) SetValue(ctx context.Context, serviceName, envKey, plaintext string) error

SetValue encrypts plaintext under serviceName's DEK, generating one on first use, and persists only the ciphertext.

func (*Manager) SetValueGuarded

func (m *Manager) SetValueGuarded(ctx context.Context, serviceName, envKey, plaintext string, overwriteLocked bool) error

SetValueGuarded is SetValue that refuses, with ErrSecretLocked, to overwrite an existing locked value unless overwriteLocked is true.

type ManagerOption added in v0.2.0

type ManagerOption func(*Manager)

ManagerOption configures a Manager.

func WithLogger added in v0.2.0

func WithLogger(l *slog.Logger) ManagerOption

WithLogger sets the logger Manager uses for legacy-format notices.

func WithRequireBound added in v0.2.0

func WithRequireBound(require bool) ManagerOption

WithRequireBound makes Resolve reject legacy unbound ciphertexts.

type MasterKey

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

MasterKey is the root key this control plane holds. It is never used to encrypt secret values directly, only to wrap and unwrap per-app data encryption keys (see DEK in dek.go). Uses age's Hybrid key type: per the age library's own current documentation, "the standard age private/public key" as of this version, safe against future cryptographically-relevant quantum computers.

func GenerateMasterKey

func GenerateMasterKey() (*MasterKey, error)

GenerateMasterKey creates a new master key. Used for bootstrapping a fresh install and in tests. A real deployment persists the resulting String() output (see LoadMasterKey) rather than regenerating on every start, generating a new key would make every previously-wrapped DEK permanently unwrappable.

func LoadMasterKey

func LoadMasterKey(serialized string) (*MasterKey, error)

LoadMasterKey parses a master key from its serialized identity string (MasterKey.String()'s output). The master key can be sourced from a file, an env var, or an external KMS interface added later. This function only handles the parsing; sourcing the string itself (reading a file, reading an env var) is the caller's job, matching internal/brand.Load's own "load from a path the caller resolved" shape rather than this package reaching into the environment itself.

func (*MasterKey) GenerateDEK

func (mk *MasterKey) GenerateDEK() (raw []byte, wrapped WrappedDEK, err error)

GenerateDEK creates a new random data encryption key and wraps it under mk. The raw key is returned for immediate use, encrypting values right away, and must never be persisted itself, only the wrapped form returned alongside it.

func (*MasterKey) String

func (mk *MasterKey) String() string

String returns the master key's serialized identity, for persisting wherever LoadMasterKey will later read it back from. Treat the result as exactly as sensitive as any other secret: never log it, never write it anywhere other than the deliberately chosen key storage location.

func (*MasterKey) UnwrapDEK

func (mk *MasterKey) UnwrapDEK(wrapped WrappedDEK) ([]byte, error)

UnwrapDEK decrypts a WrappedDEK back to its raw form, for decrypting values that were encrypted under it.

type RebindFailure added in v0.2.0

type RebindFailure struct {
	Owner  string
	Key    string
	Reason string
}

RebindFailure names one slot Rebind could not bind, never its value.

type RebindResult added in v0.2.0

type RebindResult struct {
	Scanned      int
	Rebound      int
	AlreadyBound int
	// Changed counts rows rewritten by someone else mid-run; a concurrent
	// SetValue always writes the bound format, so nothing is lost.
	Changed     int
	FailedCount int
	Failed      []RebindFailure
	Remaining   int
}

RebindResult reports one Rebind run.

type Store

type Store interface {
	GetServiceDEK(ctx context.Context, serviceName string) ([]byte, error)
	SaveServiceDEK(ctx context.Context, serviceName string, wrappedDEK []byte) error
	GetSecretValue(ctx context.Context, serviceName, envKey string) ([]byte, error)
	SaveSecretValue(ctx context.Context, serviceName, envKey string, ciphertext []byte) error
	HasSecretValue(ctx context.Context, serviceName, envKey string) (bool, error)
	HasSecretValueForServices(ctx context.Context, serviceNames []string, envKey string) (map[string]bool, error)
	DeleteServiceSecrets(ctx context.Context, serviceName string) error
	ListSecretKeys(ctx context.Context, serviceName string) ([]store.SecretKeyInfo, error)
	GetSecretKeyLocked(ctx context.Context, serviceName, envKey string) (exists, locked bool, err error)
	SetSecretLocked(ctx context.Context, serviceName, envKey string, locked bool) error
	// RotateServiceDEKs rewraps every stored DEK in one transaction; any
	// rewrap error rolls the whole rotation back.
	RotateServiceDEKs(ctx context.Context, rewrap func(serviceName string, wrapped []byte) ([]byte, error)) error
	GetMasterKeyRotatedAt(ctx context.Context) (rotatedAt time.Time, ok bool, err error)
	CountSecretValuesByPrefix(ctx context.Context, prefix []byte) (total, withPrefix int, err error)
	ListSecretCiphertexts(ctx context.Context, afterService, afterKey string, limit int) ([]store.SecretCiphertext, error)
	// ReplaceSecretCiphertext swaps old for replacement only while the row
	// still holds old, so a concurrent SetValue is never clobbered.
	ReplaceSecretCiphertext(ctx context.Context, serviceName, envKey string, old, replacement []byte) (bool, error)
}

Store is the narrow surface Manager needs from internal/store, so tests can fake it without a real database.

type WrappedDEK

type WrappedDEK []byte

WrappedDEK is a per-app data encryption key, encrypted under a MasterKey. Safe to persist at rest, useless without the matching MasterKey to unwrap it, which is the entire point of wrapping it rather than storing the raw key.

Jump to

Keyboard shortcuts

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