fix(secret-rotator): mint HA tokens under a unique name #320
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user