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
2 changed files with 39 additions and 3 deletions
+12 -3
View File
@@ -145,7 +145,16 @@ fn rotate_ha_token() -> Result<String, Box<dyn Error>> {
}
};
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<String, Box<dyn Error>> {
let orphan = ha::token_refresh_id(&fresh)
.unwrap_or_else(|_| "<unknown: could not decode new token>".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<String, Box<dyn Error>> {
// 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
+27
View File
@@ -26,6 +26,16 @@ pub fn random_secret(bytes: usize) -> Result<String, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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());
}
}