0127. The local secrets bundle is encrypted with an age passphrase

Status: Accepted; built; hardware lane B passed 2026-11-05 (restore --recover-cluster from the encrypted file not run) · Date: 2026-11-04 · Design section: Trust · Touches: 0014, 0053, 0063, 0094, 0103, 0108

Decided: ~/.ichor/clusters/<name>/secrets.yaml is written as an age file (armored, one scrypt passphrase recipient, filippo.io/age, already a dependency of the etcd backup format) instead of plaintext. ichor init, ichor adopt --recover-secrets and ichor cluster bundle-export seal new files by default; a passphrase comes from --bundle-passphrase-file PATH (any readable path, so /dev/fd/3 and /dev/stdin work), then $ICHOR_BUNDLE_PASSPHRASE, and on a terminal with neither Ichor generates one and prints it once; with neither on a non-terminal the command refuses before it writes anything, and --bundle-plaintext is the explicit, warned opt-out. Every reader (restore, adopt --secrets-bundle, cluster credentials --bundle, bundle-export's read-back, adopt's stored-bundle comparison) recognises an encrypted file by its header and takes the passphrase from the same two sources, then a no-echo prompt. A plaintext file keeps working and warns on every read; ichor secrets encrypt migrates one. The file name and path do not change, the in-cluster Secret is untouched, and the local kubeconfig stays plaintext (decision 6).

Context

ADR 0108 (S9) left secrets.yaml plaintext at mode 0600, written atomically, with a warning on group or world access, and deferred encryption. The file holds every CA key of the cluster: whoever reads it can mint a Talos os:admin or a Kubernetes system:masters identity forever, and ADR 0094 calls the matching kubeconfig the override credential and says to keep it offline. A 0600 file protects against other users on the machine only. It does not protect a laptop backup, a copy synced to a cloud drive, a stolen disk, or the file the nag tells the operator to copy "somewhere other than this machine". That last sentence is the point: a backup must be safe to park off the machine, and a plaintext one is not.

The user decided this ships in v0.1.0.

Decision

  1. Scheme: age, scrypt passphrase recipient, armored. The file is -----BEGIN AGE ENCRYPTED FILE----- text, so it survives being pasted or mailed and stays diff-able as "changed or not". It is a standard age file: age -d secrets.yaml opens it with no Ichor binary, which is the escape hatch if Ichor is not around at recovery time (no lock-in, no decrypt command to build). age was chosen over rolling our own AEAD because it is audited, already in go.mod for the etcd backup (internal/etcdbackup/crypto.go), and its header already carries the recipient type, so a later release can add recipients (an age X25519 key, an SSH key, a hardware-key plugin) without changing the file's role. Unlike the etcd backup, whose passphrase is a 256-bit HKDF output (so it uses work factor 10), this passphrase may be human-chosen, so sealing uses age's default work factor (18, about a second of CPU and 256 MiB). A passphrase given by the operator must be at least 12 characters.

  2. Key source. The passphrase is never a flag value (it would be in ps and shell history). In order: --bundle-passphrase-file PATH (one line, a single trailing newline removed; a file readable by group or others draws a warning), $ICHOR_BUNDLE_PASSPHRASE (visible to the same user and root through /proc/<pid>/environ; the guide says to prefer the file), then, only on a terminal, a no-echo prompt to read an existing file. To seal a new file on a terminal with no source, Ichor generates 150 random bits (crypto/rand, five-character groups) and prints it once on stderr; init then waits for Enter so it is not scrolled away unseen. Nothing is stored: not in the file, not in ~/.ichor, not in the cluster. Losing both the passphrase and the cluster's in-cluster Secret loses the cluster, exactly as losing a plaintext file did; the passphrase is a second item to back up, and it is small enough for a password manager, which the file no longer needs to be.

  3. Default for new files: encrypted, fail closed. Alternatives were opt-in encryption (rejected: the safe path would be the one nobody reads about, and the nag says to copy the file off the machine) and a generated passphrase printed on every run including non-interactive ones (rejected: it lands in CI logs and scrollback of a recorded session, which is worse than plaintext in a 0700 directory because it looks safe). The getting-started guide is run at a terminal, so it works unchanged apart from one extra "save this passphrase" step; automation passes --bundle-passphrase-file or sets the variable; a script that really wants the old behaviour says --bundle-plaintext and gets a warning at write time and on every later read. bundle-export on an existing encrypted file requires the passphrase of that file (verified by opening it before the replacement) so a typo cannot lock the new copy; on an existing plaintext file it follows the same default as a new file.

  4. Readers. One function, loadBundleFile, sniffs the first bytes (-----BEGIN AGE ENCRYPTED FILE----- or age-encryption.org/v1) and decrypts; it is the single place every reader goes through, so no reader can forget. Adopt's check that an earlier run's stored bundle equals the recovered one compares the decrypted bytes. A wrong passphrase and a corrupt file both fail with wrong passphrase, or the file is damaged; neither names the passphrase or any content. A file that is neither encrypted nor valid YAML fails as before.

  5. Plaintext compatibility and migration. An existing plaintext secrets.yaml, or a talosctl gen secrets file given to adopt --secrets-bundle, still loads. Each load prints ichor: warning: <path> is a plaintext secrets bundle ... run: ichor secrets encrypt <path> on stderr (the existing mode warning stays). ichor secrets encrypt CLUSTER|--file PATH reads the plaintext, seals it, opens the sealed bytes again and compares them with the original, and only then replaces the file atomically (temp file, fsync, rename, fsync of the directory, mode 0600, the same function as every other write). An already encrypted file is reported and left alone. It prints the path and, when it generated the passphrase, the passphrase once; it never prints the bundle. The old plaintext blocks may remain on the device until overwritten (flash translation layers and journals keep copies); if the file was ever exposed, rotate the CAs (ichor cluster rotate-ca) rather than rely on the migration.

  6. Other local files. ~/.ichor/clusters/<name>/kubeconfig stays plaintext 0600. kubectl, client-go and every ichor --kubeconfig read it directly, so an encrypted copy would need a decrypt-to-temp step on every use and would leave a decrypted copy lying around, which is worse. Its certificate is short-lived compared with the bundle, and the bundle can mint a new one at any time (ichor cluster credentials --bundle), so the bundle is the secret that matters and the kubeconfig is a convenience. The guide now says: once users are enabled (ADR 0094), move that kubeconfig offline or delete it and use the bundle to make a new one when the override is needed. ichor init writes no talosconfig; ichor cluster credentials writes the operator's kubeconfig and talosconfig where the operator asked, with a lifetime (ADR 0108), and those stay the operator's files.

  7. Not affected. The in-cluster Secret named by spec.secretsRef keeps the plaintext bundle: the controller reads it with no human present, and its protection is the cluster's own (RBAC, ADR 0108, and encryption at rest). The etcd backup key derives from the decrypted bundle, so backups and restore are unchanged. The file format of the bundle inside the encryption is the same YAML, so a build that predates this ADR can read a file after age -d.

Alternatives rejected

  • OS keyring (Secret Service, Keychain, Credential Manager) as the key store. No keyring on a headless Linux jump host or in CI, which is where init often runs; and the key would live on the machine that wrote the file, while the file's job is to be restorable on a different machine after a full loss (ADR 0053).
  • A machine-bound key (derived from the host or kept in ~/.ichor). Same recovery problem, and a key beside the file protects nothing.
  • An age X25519 identity file or an SSH key as the only mechanism. The identity file is another secret needing the same protection as the one it replaces; an SSH key needs an agent or its own passphrase and may be absent on the recovery machine. Both remain possible later as extra recipients in the same file format.
  • Hardware keys (age-plugin-yubikey, TPM). The plugin is an external binary that the Go library runs by name, which the "no tool installed any other way" rule and a static CLI rule out for the default path. Possible later as a recipient.
  • A decrypt command. age -d already does it, and a command that writes the bundle in plaintext to stdout or a file is a leak surface Ichor does not need.

Risks

  • A forgotten passphrase is a lost backup. Mitigated by printing it once, by the Enter prompt, by the guide's wording, and by bundle-export working from the live cluster whenever the cluster still exists.
  • The generated passphrase appears on the terminal and in its scrollback. It protects only the file, not the cluster, and is useless without the file.
  • $ICHOR_BUNDLE_PASSPHRASE is readable by the same user and root; documented, with the file and descriptor routes recommended.
  • A weak operator-chosen passphrase (12 characters or more, otherwise unchecked) is guessable offline at the cost of scrypt; the generated one is not.
  • Scripts that ran ichor init or adopt --recover-secrets non-interactively now fail until given a passphrase source or --bundle-plaintext. This is deliberate; the test harness and its README were updated.

Verification

Unit tests in internal/cli (bundlecrypt_test.go, bundlefile_crypto_test.go, secrets_encrypt_test.go, adopt_secrets_crypto_test.go, bundle_readers_crypto_test.go): seal and open round trip, wrong passphrase, a damaged file, plaintext compatibility with the warning, migration (including that the file is mode 0600 and unchanged on failure), the passphrase never appearing in output or errors, the generated passphrase printed once and never when it came from a file. Hardware follow-up: run the getting-started init on a terminal, check the printed passphrase opens secrets.yaml with age -d, run restore --recover-cluster from the encrypted file with --bundle-passphrase-file, and bundle-export after a CA rotation.

Amendment: lane B hardware findings (2026-11-05)

Hardware lane B passed every case run (refusal without a passphrase source, the generated passphrase on a terminal, age -d of the file, secrets encrypt, bundle-export, cluster credentials --bundle). Three small defects were fixed: an error was printed twice (cobra and main), now once; the plaintext-bundle warning of cluster credentials --bundle was printed twice because the file is read twice, now once per message per run (warnBundle); and the refusal of ichor secrets encrypt offered --bundle-plaintext, which that command does not have, so it now lists only the passphrase sources. Not run: restore --recover-cluster from the encrypted file, and bundle-export after a CA rotation.

Search the docs

Search pages and headings