You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
Copy file name to clipboardExpand all lines: README.md
+34-1Lines changed: 34 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -184,7 +184,7 @@ cluster:
184
184
>
185
185
> 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.
186
186
>
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.
188
188
>
189
189
> 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.
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
+
362
395
### Key Management
363
396
364
397
The `talm.key` file is generated in age keygen format and contains:
Copy file name to clipboardExpand all lines: docs/manual-test-plan.md
+32-1Lines changed: 32 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -247,6 +247,24 @@ Expected: `Updated.` on stdout. The rendered body replaces the previous body of
247
247
248
248
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.
249
249
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`):
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
+
250
268
### B5. Render with stale chart preset
251
269
252
270
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`.
# A "failed to read values file" error here is the pre-fix regression.
440
458
```
441
459
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.
443
461
444
462
### C4. Stage mode
445
463
@@ -461,6 +479,19 @@ Expected: the drift preview line for `machine.token` reads `machine.token: ***re
461
479
462
480
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.
463
481
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`):
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
+
464
495
### C6. Drift preview shows secrets with explicit opt-in
0 commit comments