From 30c0e5632aa43aa187fd1a9ae584690d1fb8f1b8 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 21 Jul 2026 12:03:23 +0200 Subject: [PATCH] fix(secret-rotator): mint HA tokens under a unique name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " [<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) --- secret-rotator/src/main.rs | 15 ++++++++++++--- secret-rotator/src/secretgen.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/secret-rotator/src/main.rs b/secret-rotator/src/main.rs index caa127ca..d1c3d670 100644 --- a/secret-rotator/src/main.rs +++ b/secret-rotator/src/main.rs @@ -145,7 +145,16 @@ fn rotate_ha_token() -> Result> { } }; - let fresh = ha.mint(&client_name, lifespan)?; + // Home Assistant refuses to mint a second long-lived token whose + // client_name already exists, and make-before-break necessarily runs while + // the outgoing token (same base name) is still present. So mint under a + // unique name: the base plus a short random tag. Correctness does not + // depend on the name — the outgoing token is found by its JWT `iss`, not by + // name — and the tag also avoids colliding with any orphan left by a prior + // failed run. Verified live: a same-name mint fails, a unique-name mint + // succeeds. + let unique_name = format!("{client_name} [{}]", secretgen::random_hex(3)?); + let fresh = ha.mint(&unique_name, lifespan)?; // If the store fails now, the freshly minted token is valid for its full // lifespan, referenced nowhere, and will never be revoked automatically @@ -156,7 +165,7 @@ fn rotate_ha_token() -> Result> { let orphan = ha::token_refresh_id(&fresh) .unwrap_or_else(|_| "".to_string()); eprintln!( - "CRITICAL: minted a new '{client_name}' token but FAILED to store it. \ + "CRITICAL: minted a new token '{unique_name}' but FAILED to store it. \ The old token is still in OpenBao and still works, so the consumer is fine, \ but the new token is orphaned and valid for {lifespan} days. \ Revoke it manually — its refresh-token id is {orphan}." @@ -167,7 +176,7 @@ fn rotate_ha_token() -> Result> { // Only now is the old token expendable — the replacement is stored. match ha.revoke(&old_id) { Ok(()) => Ok(format!( - "rotated {bao_path}#{field}: minted '{client_name}', revoked old '{old_name}' ({old_id}) — value not logged" + "rotated {bao_path}#{field}: minted '{unique_name}', revoked old '{old_name}' ({old_id}) — value not logged" )), Err(e) => { // Not fatal: the new token is already live and stored. But an old diff --git a/secret-rotator/src/secretgen.rs b/secret-rotator/src/secretgen.rs index 4354c4e2..ec765a28 100644 --- a/secret-rotator/src/secretgen.rs +++ b/secret-rotator/src/secretgen.rs @@ -26,6 +26,16 @@ pub fn random_secret(bytes: usize) -> Result> { Ok(STANDARD.encode(&buf)) } +/// A short lowercase-hex tag (`bytes*2` chars). Used to make each rotated HA +/// token's client_name unique — Home Assistant refuses to mint a second +/// long-lived token with a name that already exists, so make-before-break +/// cannot reuse the outgoing token's name. +pub fn random_hex(bytes: usize) -> Result> { + let mut buf = vec![0u8; bytes]; + getrandom::fill(&mut buf)?; + Ok(buf.iter().map(|b| format!("{b:02x}")).collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -67,3 +77,20 @@ mod tests { assert!(!random_secret(50).unwrap().contains('\n')); } } + +#[cfg(test)] +mod hex_tests { + use super::random_hex; + + #[test] + fn hex_is_the_right_length_and_charset() { + let h = random_hex(3).unwrap(); + assert_eq!(h.len(), 6); + assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + + #[test] + fn hex_varies() { + assert_ne!(random_hex(3).unwrap(), random_hex(3).unwrap()); + } +} -- 2.54.0