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()); + } +}