secrets

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 25, 2026 License: AGPL-3.0 Imports: 18 Imported by: 0

Documentation

Overview

Package secrets implements golden rule 3 for the Redoubt control plane: secret values are age-encrypted at rest and never logged, printed or displayed.

Two things live here.

Keeper holds the platform's single age X25519 identity, persisted as an age identity file (0600, outside the database, D-012) and used to encrypt every secret-bearing column (secrets.value_enc, git_sources.webhook_secret_enc, users.totp_secret_enc, …) with plain binary age ciphertext. Nothing in this package ever writes the private key anywhere but the identity file, and no error produced here contains key material or plaintext.

Redactor is the log-side half: every known secret value (and its common encodings) is replaced with "[REDACTED]" in slog records, in build/deploy log streams (Writer / Reader), and in any string passed through Redact. It is the single mechanism behind TestSecretsNeverInLogs; every slog handler and every log pipeline in platformd is wrapped by it. Redaction is a backstop, not a licence to log secrets.

Index

Constants

View Source
const MaxPendingLine = 64 << 10

MaxPendingLine is the size at which a Writer or Reader stops waiting for a newline and forwards an unterminated line. Build logs can emit very long lines (progress bars, minified output); without a cap a line with no newline would grow the buffer without bound.

A forced flush never cuts a tracked value in two: the buffered bytes are redacted as a whole and the last len(longest pattern)-1 bytes stay pending, because that is the most of a value that can have arrived without the whole value being present yet. The pending buffer is therefore bounded by MaxPendingLine plus the longest tracked pattern plus one Write (or one source Read) regardless of where the producer's chunk boundaries fall (TestForcedFlushNeverSplitsSecret).

View Source
const MinValueLength = 4

MinValueLength is the shortest value a Redactor will track, in bytes. Values shorter than this are ignored on Add: a one-to-three byte "secret" such as "a", "12" or "yes" occurs in practically every log line, so redacting it would blank out the logs (and reveal, by the pattern of holes, what the value is) without protecting anything meaningful. Real secrets (passwords, tokens, keys, TOTP seeds, webhook secrets) are far longer.

View Source
const NamePattern = `^[A-Z][A-Z0-9_]{0,127}$`

NamePattern is the accepted shape of a secret name: an environment-variable identifier, upper-case, at most 128 characters.

View Source
const Redacted = "[REDACTED]"

Redacted is the replacement text for every secret occurrence.

View Source
const ReservedPrefix = "REDOUBT_"

ReservedPrefix marks names owned by the platform (config.Env*); apps may not shadow them.

Variables

View Source
var (
	ErrNoIdentity         = errors.New("secrets: keeper has no identity")
	ErrMalformedIdentity  = errors.New("secrets: identity file is not a single age X25519 identity")
	ErrIdentityPermission = errors.New("secrets: identity file must be a regular file with mode 0600")
	ErrCiphertext         = errors.New("secrets: ciphertext is malformed or was not encrypted for this identity")
	ErrIdentityTooLarge   = errors.New("secrets: identity file is unexpectedly large")
)

Errors returned by identity handling. They deliberately carry no key material: an identity file that fails to parse is reported as ErrMalformedIdentity without the parser's message, because age's parse errors quote the offending line.

View Source
var ErrInvalidName = errors.New("invalid secret name")

ErrInvalidName wraps every ValidateName failure.

View Source
var ErrWriterClosed = errors.New("secrets: write to closed redacting writer")

ErrWriterClosed is returned by Write after Close.

Functions

func ValidateName

func ValidateName(name string) error

ValidateName reports whether name may be used as a secret (environment variable) name.

A name that does not match NamePattern is rejected without being echoed: a user who pastes a value into the name field must not see it come back in an error that is then logged. Reserved and denied names are well-formed identifiers and are named in the error.

Types

type Keeper

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

Keeper encrypts and decrypts with the platform's age X25519 identity.

func LoadOrCreateIdentity

func LoadOrCreateIdentity(path string) (*Keeper, error)

LoadOrCreateIdentity loads the age identity file at path, or generates a fresh X25519 identity and writes it there when the file does not exist yet.

The parent directory must already exist (config.EnsureDirs creates keys/ with 0700). A new file is created with O_EXCL and mode 0600 so a concurrent creator cannot race the write and the umask cannot widen it. An existing path is refused when it is a symlink, not a regular file, or has any group/other permission bits: any of those would let another local user read or swap the key. The key itself is never logged and never appears in an error.

func NewEphemeralKeeper

func NewEphemeralKeeper() (*Keeper, error)

NewEphemeralKeeper returns a Keeper with a freshly generated identity that is never persisted. It exists for tests and for one-off tooling; platformd always uses LoadOrCreateIdentity.

func (*Keeper) Decrypt

func (k *Keeper) Decrypt(ciphertext []byte) ([]byte, error)

Decrypt decrypts age ciphertext produced by Encrypt. Tampered, truncated or foreign ciphertext yields ErrCiphertext; no plaintext or key material is ever part of the error.

func (*Keeper) DecryptReader

func (k *Keeper) DecryptReader(r io.Reader) (io.Reader, error)

DecryptReader returns a streaming plaintext reader for an age ciphertext stream (backups).

func (*Keeper) DecryptString

func (k *Keeper) DecryptString(ciphertext []byte) (string, error)

DecryptString is Decrypt returning a string.

func (*Keeper) Encrypt

func (k *Keeper) Encrypt(plaintext []byte) ([]byte, error)

Encrypt returns binary (non-armored) age ciphertext of plaintext for this identity.

func (*Keeper) EncryptString

func (k *Keeper) EncryptString(s string) ([]byte, error)

EncryptString is Encrypt for a string value.

func (*Keeper) Recipient

func (k *Keeper) Recipient() string

Recipient returns the public age1… recipient string. It is safe to log.

type Redactor

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

Redactor replaces known secret values with Redacted. It is safe for concurrent use: Add and Remove take the write lock; Redact, Writer, Reader and Handler snapshot the pattern set under the read lock and never hold it while doing I/O.

func NewRedactor

func NewRedactor() *Redactor

NewRedactor returns an empty Redactor.

func (*Redactor) Add

func (r *Redactor) Add(values ...string)

Add starts redacting values. Empty values and values shorter than MinValueLength are ignored (see MinValueLength). For each value the redactor also recognises:

  • its base64 encodings (standard and URL alphabets, padded and unpadded), including the alignment-independent core that appears when the value is embedded in a larger base64 blob (an "Authorization: Basic …" header, a base64 env dump);
  • its URL percent-encodings (query and path forms);
  • its JSON string-escaped forms (with and without HTML escaping);
  • each individual line of a multi-line value (PEM keys), so that line-oriented streams still catch every line of a key that was printed whole;
  • its whitespace-trimmed form.

Derived forms shorter than MinValueLength are dropped for the same reason short values are.

func (*Redactor) Handler

func (r *Redactor) Handler(h slog.Handler) slog.Handler

Handler wraps h so that every record's message and every attribute value (strings, errors, Stringers, byte slices, arbitrary values and nested groups) is redacted before h sees it.

Attributes attached with WithAttrs and groups opened with WithGroup are not forwarded to h when they are attached: a handler such as slog's JSON or text handler would pre-format them into its buffer at that moment, freezing whatever the pattern set was then. Instead the wrapper records the chain of WithAttrs/WithGroup operations and replays it onto every record in Handle, redacting against the pattern set current at that moment, so a value registered with Redactor.Add after logger.With(...) was called is still redacted on every later record (TestRedactingSlogHandlerWithBeforeAdd). The resulting attribute structure — top-level attrs before the first group, nested groups, record attrs innermost — is the same one h would have produced from the bare chain (TestRedactingSlogHandlerPreservesStructure).

Non-string values that a handler would otherwise format itself (errors, fmt.Stringers, any other Kind Any value) are pre-rendered with %+v and passed on as strings so that the rendered text can be redacted; numbers, booleans, times and durations pass through untouched.

func (*Redactor) Len

func (r *Redactor) Len() int

Len returns the number of tracked values.

func (*Redactor) Reader

func (r *Redactor) Reader(rd io.Reader) io.Reader

Reader returns an io.Reader that yields rd's bytes with every line redacted. Lines are forwarded once complete (or once MaxPendingLine bytes have accumulated); the final unterminated line is redacted and forwarded when rd reports EOF or any other error, which is then returned after the data.

func (*Redactor) Redact

func (r *Redactor) Redact(s string) string

Redact returns s with every occurrence of every tracked value (and its encodings) replaced by Redacted. A nil Redactor redacts nothing.

func (*Redactor) RedactBytes

func (r *Redactor) RedactBytes(b []byte) []byte

RedactBytes is Redact for a byte slice; it returns a new slice.

func (*Redactor) Remove

func (r *Redactor) Remove(values ...string)

Remove stops redacting values that were previously added. Unknown values are ignored.

func (*Redactor) Writer

func (r *Redactor) Writer(w io.Writer) io.WriteCloser

Writer returns a line-buffered io.WriteCloser that redacts every line before writing it to w. A secret split across Write calls is still redacted because lines are only forwarded once complete (or once MaxPendingLine bytes have accumulated). Close redacts and flushes any partial trailing line; it does not close w. The returned writer is safe for concurrent use.

Jump to

Keyboard shortcuts

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