secrets

package
v0.2.0-beta.2 Latest Latest
Warning

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

Go to latest
Published: Sep 18, 2026 License: Apache-2.0 Imports: 14 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

This section is empty.

Variables

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

ErrSecretLocked is returned by SetValueGuarded when overwriting an existing, locked value without overwriteLocked=true. Unlike ErrValueNotFound this is not "nothing to do", it's "something exists and refused to be replaced" -- callers (internal/api) should surface it as a 409, not a generic 500.

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, returned by Resolve regardless of whether the underlying cause was a missing DEK (no value ever set for this service) or a missing ciphertext (DEK exists, this particular key doesn't): callers only need to know "nothing to resolve", not which.

Functions

func DecryptValue

func DecryptValue(dek []byte, ciphertext []byte) (string, error)

DecryptValue reverses EncryptValue.

func EncryptValue

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

EncryptValue encrypts plaintext under dek (a raw, unwrapped data encryption key from GenerateDEK or UnwrapDEK) using AES-256-GCM. The returned bytes are nonce-prefixed ciphertext, self-contained: nothing else needs to be stored alongside it to decrypt later, other than the dek itself.

AES-GCM, not age directly, for actual secret values: DEKs are meant to be reused across every secret value for one app (that's the point of the envelope, wrap the DEK once, encrypt many values fast with it), and symmetric AES-GCM is the right tool for repeated same-key encryption; age's own asymmetric machinery is reserved for the one wrap operation per DEK in dek.go.

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 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: generate-or-reuse a service's DEK, encrypt a value under it, persist only ciphertext and a wrapped key, and reverse all of that to get a plaintext value back. Callers never see a raw DEK or handle wrapping/unwrapping themselves.

func NewManager

func NewManager(store Store, mk *MasterKey) *Manager

NewManager builds a Manager. mk is the control plane's master key, held only in memory (every DEK is wrapped by a master key held only by the control plane), sourced by the caller the same way MasterKey.String/LoadMasterKey already document (file, env, or a future KMS interface), never by this package.

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 (via any past SetValue call) can ever be decrypted again, even if the underlying ciphertext somehow survives elsewhere. For a sentinel key like store.GitHubAppSecretsKey() this deletes the whole logical secret bundle at once, not one field at a time.

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. internal/deploy uses this to fail a deploy loudly when a { secret: true, required: true } env var has no value yet, rather than deferring the check to container-create time.

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) 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. Callers (the application controller, immediately before docker.Runtime.Create) 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 manager's currently active master key to newMasterKey (its serialized age identity string), then swaps the active key on success. Held for the whole operation is Manager's write lock, so no concurrent SetValue/Resolve call can create a new DEK under the old key, or read one under it, while rotation is in flight: dekFor/Resolve above are blocked, not racing, for that window.

Returns the rotation's timestamp on success. On any failure (a corrupt stored DEK, most likely), nothing changes: the DB rotation itself is transactional (Store.RotateServiceDEKs) and the in-memory key is only swapped after that transaction 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 (and, on first use, the wrapped DEK). The plaintext itself is never persisted anywhere by this call.

func (*Manager) SetValueGuarded

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

SetValueGuarded is SetValue with a reversible per-key overwrite guard: if a value already exists for (serviceName, envKey) and is locked, this returns ErrSecretLocked without writing anything, unless overwriteLocked is true. Only the general app-secrets path (internal/api's SecretSetter) needs this; every other secret-backed feature (OAuth client secrets, email SMTP password, backup credentials, git source tokens, database passwords, the GitHub App's own secrets) keeps calling the plain SetValue above, unaffected.

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 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)
	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:
	// rewrap is called once per (serviceName, wrapped DEK) row, and any
	// error it returns aborts and rolls back the whole rotation, never
	// leaving some rows migrated and others not. See rotate.go.
	RotateServiceDEKs(ctx context.Context, rewrap func(serviceName string, wrapped []byte) ([]byte, error)) error
	// GetMasterKeyRotatedAt returns the last time RotateServiceDEKs
	// committed successfully, or ok=false if it never has.
	GetMasterKeyRotatedAt(ctx context.Context) (rotatedAt time.Time, ok bool, err error)
}

Store is the narrow surface Manager needs from internal/store, so tests can fake it without a real database. *store.DB satisfies this structurally, the same "narrow interface at the boundary" shape every other package here uses (application.ServiceStore, deploy.ServiceStore, and so on). This does mean internal/secrets depends on internal/store, unlike the rest of this package (the primitives in masterkey.go, dek.go, value.go know nothing about storage): store.ErrServiceDEKNotFound and store.ErrSecretValueNotFound are real sentinels this file checks with errors.Is, and every other consumer package (internal/deploy, internal/reconcile/application, internal/api) already imports internal/store directly for exactly this reason. internal/store does not import internal/secrets, so this stays one-directional, no cycle.

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