fix(secret-rotator): mint HA tokens under a unique name #320

Merged
k.renner merged 1 commits from fix/ha-token-unique-name into master 2026-07-21 10:06:17 +00:00
Member

Found by testing ha-token mode against live Home Assistant before wiring the CronJobs into GitOps — the mode as merged in #318 cannot actually rotate an existing token.

The bug

Home Assistant refuses to mint a second long-lived access token whose client_name already exists. Make-before-break necessarily mints the replacement while the outgoing token — same base name — is still present, so every real rotation fails at mint:

same-name mint   -> FAIL {'code':'unknown_error','message':'Unknown error'}
unique-name mint -> SUCCESS

Directly observed against HA 2026.7.2. The ad-hoc test during #318 only passed because it minted a new name ("Config Sync"), never exercising the collision.

The failure was safe — mint fails before store or revoke, so the old token stayed valid and stored (a no-op, verified: the stored token fingerprint was unchanged after the failed run). But nothing would ever rotate.

The fix

Mint under <base> [<6 hex>]. Correctness doesn't depend on the name — the outgoing token is identified by its JWT iss, never by name — so a random tag costs nothing, and it also avoids colliding with an orphan from a prior failed run. HA_CLIENT_NAME stays the recognizable base ("ServiceMonitor", "Shelly Manager", …); the stored token just carries a rotating suffix.

Verified end-to-end

Against live HA on a throwaway token (zero blast radius — no live consumer touched):

  • unique-name mint succeeds where a reused base name fails
  • the outgoing token is found by its iss and confirmed an LLAT
  • store patches OpenBao
  • the old token is revoked and stops authenticating (auth_invalid)

clippy -D warnings clean; 12 tests pass (adds random_hex length/charset/uniqueness).

Why a separate PR

The binary (#318) is already merged and its image published. This is the corrected binary; once merged, CI republishes the image and the Home Assistant CronJob PR can reference the new digest. That CronJob PR also has to add a Reloader annotation for shelly-manager-llat — testing turned up that shelly-manager reads it as a startup env var but its only Reloader annotation points at a different secret, so a rotation would not restart it.

🤖 Generated with Claude Code

Found by testing `ha-token` mode against live Home Assistant **before** wiring the CronJobs into GitOps — the mode as merged in #318 cannot actually rotate an existing token. ## The bug Home Assistant refuses to mint a second long-lived access token whose `client_name` already exists. Make-before-break necessarily mints the replacement **while the outgoing token — same base name — is still present**, so every real rotation fails at mint: ``` same-name mint -> FAIL {'code':'unknown_error','message':'Unknown error'} unique-name mint -> SUCCESS ``` Directly observed against HA 2026.7.2. The ad-hoc test during #318 only passed because it minted a *new* name ("Config Sync"), never exercising the collision. The failure was **safe** — mint fails before store or revoke, so the old token stayed valid and stored (a no-op, verified: the stored token fingerprint was unchanged after the failed run). But nothing would ever rotate. ## The fix Mint under `<base> [<6 hex>]`. Correctness doesn't depend on the name — the outgoing token is identified by its JWT `iss`, never by name — so a random tag costs nothing, and it also avoids colliding with an orphan from a prior failed run. `HA_CLIENT_NAME` stays the recognizable base ("ServiceMonitor", "Shelly Manager", …); the stored token just carries a rotating suffix. ## Verified end-to-end Against live HA on a **throwaway** token (zero blast radius — no live consumer touched): - unique-name mint succeeds where a reused base name fails - the outgoing token is found by its `iss` and confirmed an LLAT - store patches OpenBao - the old token is revoked and stops authenticating (`auth_invalid`) `clippy -D warnings` clean; 12 tests pass (adds `random_hex` length/charset/uniqueness). ## Why a separate PR The binary (#318) is already merged and its image published. This is the corrected binary; once merged, CI republishes the image and the Home Assistant CronJob PR can reference the new digest. That CronJob PR also has to add a Reloader annotation for `shelly-manager-llat` — testing turned up that shelly-manager reads it as a startup env var but its only Reloader annotation points at a *different* secret, so a rotation would not restart it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
claude-bot added 1 commit 2026-07-21 10:03:25 +00:00
fix(secret-rotator): mint HA tokens under a unique name
ci/woodpecker/push/build-ci-images Pipeline was successful
30c0e5632a
Found by testing ha-token mode against live HA before wiring up the CronJobs.
Home Assistant refuses to mint a second long-lived access token whose
client_name already exists — and make-before-break necessarily mints while the
outgoing token (same base name) is still present. So every real rotation would
fail at the mint step with "Unknown error", exactly as observed:

  same-name mint   -> FAIL {'code':'unknown_error'}
  unique-name mint -> SUCCESS

The earlier ad-hoc test only passed because it minted a NEW name
("Config Sync") rather than rotating an existing one.

Fix: mint under "<base> [<6 hex>]". Correctness does not depend on the name —
the outgoing token is identified by its JWT `iss`, never by name — so a random
tag is free, and it also avoids colliding with an orphan left by a prior failed
run. The failure mode was safe throughout: mint fails before store or revoke,
so the old token stays valid and stored (a no-op, not an outage).

Verified end-to-end against live HA on a throwaway token: unique mint succeeds,
the outgoing token is found by iss, store patches OpenBao, and the old token is
revoked and stops authenticating.

clippy -D warnings clean; 12 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
k.renner merged commit db6a17cbfd into master 2026-07-21 10:06:16 +00:00
Sign in to join this conversation.