Skip to content

Commit 5e8e522

Browse files
committed
feat(template): keep encrypted secret values out of committed node files and previews
Templates that consume encrypted user values still exposed the rendered secret in three places: 'talm template -I' baked it into nodes/*.yaml, plain 'talm template' printed it to stdout, and 'talm apply --dry-run' printed it in the drift preview — defeating the point of encrypting at rest. Seal values that originate from an encrypted value file out of all three surfaces. 'template -I' omits them structurally: a secret that is a direct map value drops its key (apply's map merge leaves the rendered value authoritative); a secret nested in a sequence element drops the whole element (sequences without a merge key are matched by whole-element deep-equal, so a partial element would be appended as a duplicate at apply). The real value is re-injected only at apply, which re-renders from the encrypted file in memory. Plain 'talm template' to stdout redacts the values to a sentinel by default; --show-secrets prints them verbatim. The apply drift preview previously redacted only a static allowlist of Talos bootstrap field paths, so a user secret at any other path leaked verbatim on --dry-run. Extend the drift redactor with the user secret set (value-based, covering secrets nested in slices) gated by the existing --show-secrets-in-drift, so template and apply mask the same values symmetrically. Sealing and redaction are structural (YAML-node) and value-based, never substring, so they cannot corrupt quoting or match a value mid-string, and are deterministic so repeated -I runs produce no git churn. A round-trip test drives the omitted body back through MergeFileAsPatch and asserts the real secret survives exactly once. Value-based matching is exact across the whole config; a secret colliding with an ordinary string seals that field too — a documented sharp edge, pinned by test. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent 77cc49a commit 5e8e522

18 files changed

Lines changed: 1445 additions & 59 deletions

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ cluster:
184184
>
185185
> 1. **Declared-resource existence** (`--skip-resource-validation` opt-out, default on). Before sending the config to the node, the gate walks the rendered MachineConfig, extracts every reference to a host-side resource (network links from v1.12 multi-doc — `LinkConfig.name`, `BondConfig.links[]`, `VLANConfig.parent`, `BridgeConfig.links[]`, `Layer2VIPConfig.link`, `HCloudVIPConfig.link`, `DHCPv4Config.name` / `DHCPv6Config.name` / `EthernetConfig.name`; v1.11 legacy `machine.network.interfaces[].interface`; install disk via `machine.install.disk` literal or `machine.install.diskSelector`; `UserVolumeConfig.provisioning.diskSelector`), and verifies each against the node's COSI `LinkStatus`/`Disk` snapshots. A reference that doesn't resolve fails the apply with a `[blocker]` line listing the available names so the typo or migration miss is fixable from the values without re-running discovery. Disk selectors must match at least one (non-readonly, non-CDROM, non-virtual) disk — zero matches block, multiple matches warn (install picks the first). Virtual-link-creator documents (`BondConfig.name`, `VLANConfig.name`, `BridgeConfig.name`, `WireguardConfig.name`, `DummyLinkConfig.name`, `LinkAliasConfig.name`) are intentionally NOT validated against existing links — those `.name` fields describe new virtual links the apply is creating, not references to pre-existing host resources. The gate also runs a syntactic net-addr walker against `StaticHostConfig.name` (must parse as an IP literal — the `name` field on this kind doubles as the IP the hostnames map to), `NetworkRuleConfig.ingress[].subnet` and `.except` (per-entry CIDR), and `WireguardConfig.peers[].endpoint` (host:port; empty / absent endpoint is a listener-only peer, NOT a finding). Out of scope today: `machine.disks[].device` (extra-disk partitioning); track in a follow-up if you need it. Pass `--skip-resource-validation` for recovery into a maintenance image with mismatched hardware or pre-staging values for hardware that isn't installed yet.
186186
>
187-
> 2. **Pre-apply drift preview** (`--skip-drift-preview` opt-out, default on). Reads the node's current MachineConfig via COSI and prints a `+`/`-`/`~`/`=` diff of what's about to change, keyed by `(kind, name)`. Informational only — never blocks. The `-` lines are the most useful: they surface stale documents from a previous apply that the new render no longer emits (e.g. an `eth1` LinkConfig lingering after a migration to `eth0`). Reading the current config requires the auth path — `MachineConfig` is a Sensitive COSI resource and is unreachable on the `--insecure` maintenance connection; the gate prints `drift verification unavailable on maintenance connection` (per-node-prefixed on multi-node insecure apply) and proceeds in that case. Secret-bearing field values (`cluster.token`, `cluster.{ca,aggregatorCA,serviceAccount,etcd.ca}.key`, `machine.token` / `machine.ca.key`, the `cluster.acceptedCAs` / `machine.acceptedCAs` slices, `WireguardConfig.privateKey`, the `peers` slice carrying `presharedKey`s) are redacted by default — both sides render as `***redacted (len=N)***` so a rotation surfaces as different-length sentinels without leaking the value. Pass `--show-secrets-in-drift` to see the raw values verbatim (debugging only — disables the redaction for the run). **`--dry-run` runs this gate** — the diff is read-only and "show me what would change" is exactly the dry-run contract.
187+
> 2. **Pre-apply drift preview** (`--skip-drift-preview` opt-out, default on). Reads the node's current MachineConfig via COSI and prints a `+`/`-`/`~`/`=` diff of what's about to change, keyed by `(kind, name)`. Informational only — never blocks. The `-` lines are the most useful: they surface stale documents from a previous apply that the new render no longer emits (e.g. an `eth1` LinkConfig lingering after a migration to `eth0`). Reading the current config requires the auth path — `MachineConfig` is a Sensitive COSI resource and is unreachable on the `--insecure` maintenance connection; the gate prints `drift verification unavailable on maintenance connection` (per-node-prefixed on multi-node insecure apply) and proceeds in that case. Secret-bearing field values (`cluster.token`, `cluster.{ca,aggregatorCA,serviceAccount,etcd.ca}.key`, `machine.token` / `machine.ca.key`, the `cluster.acceptedCAs` / `machine.acceptedCAs` slices, `WireguardConfig.privateKey`, the `peers` slice carrying `presharedKey`s) are redacted by default — both sides render as `***redacted (len=N)***` so a rotation surfaces as different-length sentinels without leaking the value. In addition to that static path allowlist, any value originating from an encrypted user value file (`*.encrypted.yaml` referenced via `templateOptions.valueFiles`) is redacted **by value** wherever it surfaces in the diff (at any path, including nested in a slice) — symmetric with how `talm template` redacts the same values. Pass `--show-secrets-in-drift` to see the raw values verbatim (debugging only — disables both the path-based and value-based redaction for the run). **`--dry-run` runs this gate** — the diff is read-only and "show me what would change" is exactly the dry-run contract.
188188
>
189189
> 3. **Post-apply state verification** (`--skip-post-apply-verify` opt-out, **default off** pending a Talos-mutated-field allowlist). After `ApplyConfiguration` returns success, re-reads the on-node MachineConfig and structurally compares it against the bytes that were sent. Divergence blocks the apply chain with a per-document diff, primarily catching silent doc drops (Talos parser ignored an unknown field) and controller reverts. Disabled by default because Talos mutates a handful of leaf fields post-apply (cert hashes, timestamps) that would surface as false-positive divergence without an allowlist. The verify runs only on `--mode=no-reboot`. `--mode=staged`, `--mode=try`, `--mode=reboot`, and `--mode=auto` all skip the gate — each for a documented reason: staged stores rather than activates; try auto-rolls back; reboot kills the COSI connection mid-verify; auto is promoted by Talos to REBOOT internally when the change requires it, so the verify would race the reboot. `--dry-run` skips it too.
190190
>
@@ -341,6 +341,7 @@ This command will:
341341
- Encrypt `secrets.yaml` → `secrets.encrypted.yaml`
342342
- Encrypt `talosconfig` → `talosconfig.encrypted`
343343
- Encrypt `kubeconfig` → `kubeconfig.encrypted` (if exists)
344+
- Encrypt `values-secret.yaml` → `values-secret.encrypted.yaml` (if exists)
344345
- Update `.gitignore` with sensitive files
345346

346347
### Decrypting Files
@@ -357,8 +358,40 @@ This command will:
357358
- Decrypt `secrets.encrypted.yaml` → `secrets.yaml`
358359
- Decrypt `talosconfig.encrypted` → `talosconfig`
359360
- Decrypt `kubeconfig.encrypted` → `kubeconfig` (if exists)
361+
- Decrypt `values-secret.encrypted.yaml` → `values-secret.yaml` (if exists)
360362
- Update `.gitignore` with sensitive files
361363

364+
### Encrypted user values
365+
366+
Beyond Talos' own PKI/tokens, you can store **arbitrary secret values that chart templates consume** (a registry password, a KMS plugin's secret-id, etc.) encrypted at rest with the same `talm.key`:
367+
368+
1. Author the secrets in plaintext `values-secret.yaml` (git-ignored), e.g.:
369+
370+
```yaml
371+
registryPassword: hunter2
372+
```
373+
374+
2. Encrypt them with `talm init --encrypt` → produces the committable `values-secret.encrypted.yaml` (per-value `ENC[AGE,...]` envelopes; keys stay readable).
375+
376+
3. Reference the **encrypted** file from `Chart.yaml`:
377+
378+
```yaml
379+
templateOptions:
380+
valueFiles:
381+
- values-secret.encrypted.yaml
382+
```
383+
384+
`talm template` and `talm apply` decrypt it **in memory** via `talm.key` — the plaintext never has to be present at render time. Both commands now honor the full value-source set (`--values`, `--set`, `--set-string`, `--set-file`, `--set-json`, `--set-literal`) plus `templateOptions.*`, so a value renders identically whether you preview with `template` or push with `apply`.
385+
386+
Secret values are kept out of committed and printed output:
387+
388+
- `talm template -I` **omits** secret-bearing fields from the rendered `nodes/*.yaml`; the real value is re-injected only at `apply` (which re-renders from the encrypted file).
389+
- `talm template` (stdout) and `talm apply` (drift preview) **redact** them to `***` by default. Reveal verbatim with `talm template --show-secrets` / `talm apply --show-secrets-in-drift` (debugging only).
390+
391+
> Reference the encrypted file from `Chart.yaml templateOptions.valueFiles` (as shown above), NOT only via `template --values`. The node-file modeline does not persist value files, so `apply` only re-reads what is in `Chart.yaml` (plus its own `--values`). If an encrypted file is passed solely to `template -I`, the omitted secret is absent from the node file AND never re-rendered at apply — silently lost from the applied config. `template -I` prints a warning when it omits secrets from a file that is not in `Chart.yaml`.
392+
393+
> Sealing matches by exact value across the whole rendered config, so do not encrypt low-entropy values that collide with ordinary config strings (e.g. a bare port, or a password literally set to `controlplane`) — that unrelated field would be sealed too. Prefer high-entropy secrets. Secret values must be strings (quote them in `values-secret.yaml`); the encryption only covers string leaves.
394+
362395
### Key Management
363396

364397
The `talm.key` file is generated in age keygen format and contains:

docs/manual-test-plan.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,24 @@ Expected: `Updated.` on stdout. The rendered body replaces the previous body of
247247

248248
Regression anchor: write `nodes/node0.yaml` as `# Operator note A\n# Operator note B\n# talm: ...\n<body>`. After `template -I -f nodes/node0.yaml`, the first two lines (`# Operator note A`, `# Operator note B`) MUST still be there, followed by the modeline, the talm-rendered warning header, then the body. Re-run idempotent: a second `template -I` keeps the same prefix structure — leading comments don't drift, multiply, or disappear.
249249

250+
### B4a. In-place omits secret values; stdout redacts them
251+
252+
With an encrypted value file in scope (see B2a) and a template that injects a secret into the body (e.g. a `machine.pods[]` env, or a `machine.registries.config.<reg>.auth.password`):
253+
254+
```bash
255+
cd $PROJECT
256+
talm template -I -f nodes/node0.yaml
257+
grep -q hunter2 nodes/node0.yaml && echo "FAIL: secret baked into node file" || echo "OK: secret omitted"
258+
talm template -I -f nodes/node0.yaml && git diff --quiet nodes/node0.yaml && echo "OK: idempotent (no churn)" || echo "churn on 2nd -I"
259+
talm template -f nodes/node0.yaml | grep -c hunter2 # 0 — redacted by default
260+
talm template --show-secrets -f nodes/node0.yaml | grep -c hunter2 # >=1 — verbatim opt-in
261+
talm apply --dry-run -f nodes/node0.yaml # real secret re-injected at apply
262+
```
263+
264+
Expected: `template -I` writes a node file with NO plaintext (or ciphertext) secret — the secret field is omitted, the real value is re-rendered only at apply. A second `-I` produces no git churn (age is randomized; omission sidesteps it). Plain `template` to stdout redacts the secret to `***`; `--show-secrets` prints it. At `apply`, the drift/dry-run shows the real secret value (re-rendered in memory from the encrypted file), and a secret nested in `machine.pods[]` is NOT duplicated in the applied config.
265+
266+
Regression anchor (replace-semantic lists): put a secret as one element of a multi-element list at a `merge:"replace"` path — `cluster.network.podSubnets`, `serviceSubnets`, `cluster.apiServer.auditPolicy`, an ingress rule, or `portSelector/ports`. After `template -I`, the whole key must be absent from the node file (NOT a partial list with the secret element removed). After `talm apply`, the applied config must contain BOTH the secret element and its non-secret siblings — a partial body list would overwrite the rendered list and silently drop the secret element. Verify the secret value is present in `talm apply --dry-run --show-secrets-in-drift` output and the sibling is not lost.
267+
250268
### B5. Render with stale chart preset
251269

252270
When the local `charts/talm/` is older than the talm binary's embedded preset, `talm template` succeeds against the local preset — it does NOT auto-bump. The operator must run `init --update`. Confirm by inspecting `talm version` against `Chart.yaml`.
@@ -439,7 +457,7 @@ cd /tmp && talm apply --dry-run -f "$PROJECT/nodes/node0.yaml"
439457
# A "failed to read values file" error here is the pre-fix regression.
440458
```
441459

442-
Regression anchor: `talm template` and `talm apply` must render the SAME config for the same value inputs (`--values` / `--set` / Chart.yaml `templateOptions.valueFiles`). A value that renders under `template` but is empty / `required`-fails under `apply` is the #221 blocker resurfacing.
460+
Regression anchor: `talm template` and `talm apply` must render the SAME config for the same value inputs (`--values` / `--set` / Chart.yaml `templateOptions.valueFiles`). A value that renders under `template` but is empty / `required`-fails under `apply` is a regression of the template↔apply value consistency.
443461

444462
### C4. Stage mode
445463

@@ -461,6 +479,19 @@ Expected: the drift preview line for `machine.token` reads `machine.token: ***re
461479

462480
Regression anchor: rotating any field in the allowlist (`cluster.{secret,token,aescbcEncryptionSecret,secretboxEncryptionSecret}`, `cluster.{ca,aggregatorCA,serviceAccount,etcd.ca}.key`, `cluster.acceptedCAs[].key`, `machine.{token,ca.key}`, `machine.acceptedCAs[].key`) MUST redact. A regression that silently leaks a secret value into stderr is a security-class bug — verify the substring with `grep -F` against the captured output.
463481

482+
### C5a. Drift preview redacts user secret values (encrypted value files)
483+
484+
With an encrypted value file in scope (B2a) injecting a secret into a NON-allowlisted field (e.g. a `machine.pods[]` env, or `machine.registries.config.<reg>.auth.password`):
485+
486+
```bash
487+
talm apply --dry-run -f nodes/node0.yaml 2>&1 | grep -F hunter2 && echo "FAIL: user secret leaked" || echo "OK: redacted"
488+
talm apply --dry-run --show-secrets-in-drift -f nodes/node0.yaml 2>&1 | grep -cF hunter2 # >=1 with explicit opt-in
489+
```
490+
491+
Expected: a value authored in `values-secret.encrypted.yaml` is redacted in the drift preview by default — symmetric with `talm template`, which redacts the same value on stdout. The value appears verbatim only under `--show-secrets-in-drift`. This is value-based (not path-based) redaction, so it covers user secrets wherever a template places them. A leak here is a security-class bug — `apply --dry-run` is the common CI pre-apply command.
492+
493+
Regression anchor: the redaction is value-based and exact-match. A secret whose plaintext coincides with an ordinary structural string (e.g. a password literally set to `controlplane` or a bare port) will also redact that unrelated field — a documented sharp edge of value-based sealing, not a bug. Do not encrypt low-entropy values that collide with config strings.
494+
464495
### C6. Drift preview shows secrets with explicit opt-in
465496

466497
```bash

0 commit comments

Comments
 (0)