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
- Variables
- func ValidateName(name string) error
- type Keeper
- func (k *Keeper) Decrypt(ciphertext []byte) ([]byte, error)
- func (k *Keeper) DecryptReader(r io.Reader) (io.Reader, error)
- func (k *Keeper) DecryptString(ciphertext []byte) (string, error)
- func (k *Keeper) Encrypt(plaintext []byte) ([]byte, error)
- func (k *Keeper) EncryptString(s string) ([]byte, error)
- func (k *Keeper) Recipient() string
- type Redactor
- func (r *Redactor) Add(values ...string)
- func (r *Redactor) Handler(h slog.Handler) slog.Handler
- func (r *Redactor) Len() int
- func (r *Redactor) Reader(rd io.Reader) io.Reader
- func (r *Redactor) Redact(s string) string
- func (r *Redactor) RedactBytes(b []byte) []byte
- func (r *Redactor) Remove(values ...string)
- func (r *Redactor) Writer(w io.Writer) io.WriteCloser
Constants ¶
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).
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.
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.
const Redacted = "[REDACTED]"
Redacted is the replacement text for every secret occurrence.
const ReservedPrefix = "REDOUBT_"
ReservedPrefix marks names owned by the platform (config.Env*); apps may not shadow them.
Variables ¶
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.
var ErrInvalidName = errors.New("invalid secret name")
ErrInvalidName wraps every ValidateName failure.
var ErrWriterClosed = errors.New("secrets: write to closed redacting writer")
ErrWriterClosed is returned by Write after Close.
Functions ¶
func ValidateName ¶
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 ¶
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 ¶
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 ¶
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 ¶
DecryptReader returns a streaming plaintext reader for an age ciphertext stream (backups).
func (*Keeper) DecryptString ¶
DecryptString is Decrypt returning a string.
func (*Keeper) Encrypt ¶
Encrypt returns binary (non-armored) age ciphertext of plaintext for this identity.
func (*Keeper) EncryptString ¶
EncryptString is Encrypt for a string value.
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 (*Redactor) Add ¶
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 ¶
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) 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 ¶
Redact returns s with every occurrence of every tracked value (and its encodings) replaced by Redacted. A nil Redactor redacts nothing.
func (*Redactor) RedactBytes ¶
RedactBytes is Redact for a byte slice; it returns a new slice.
func (*Redactor) Remove ¶
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.