From 2e18d68a945b2864ca26e5e81c8b69d33b5a3a21 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Tue, 21 Jul 2026 12:14:55 +0200 Subject: [PATCH] fix(secret-rotator): revoke old HA token over a new-token session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the ha-token rotation end-to-end against live HA. The rotation was fully correct — new token minted, stored, and the old one revoked — but the job logged a false "OLD token REMAINS VALID" warning. Root cause: HA binds a WebSocket session to the refresh token behind its access token. The rotator authenticated with the OLD token and revoked that same token over that same session, so HA tore the session down as it processed the delete — the delete succeeded server-side but the ack could not be read, surfacing as "connection closed by HA". Verified: after the false-warning run, the old token's refresh id was already gone from HA. Fix: after storing the new token, drop the old session and reconnect with the NEW token to perform the revoke. The session is then not the one being deleted, so the ack reads cleanly and the report is accurate. This matters beyond cosmetics: the false warning fired on every successful rotation, which would train an operator to ignore it — and a genuine revoke failure would then be indistinguishable noise. With the fix, a warning means a real un-revoked token. Verified end-to-end against live HA on a throwaway token: revoke over the new session returns success=True and the old token is confirmed gone (where the old flow got "connection closed"). clippy -D warnings clean; 12 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- secret-rotator/src/main.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/secret-rotator/src/main.rs b/secret-rotator/src/main.rs index d1c3d670..7d98ae67 100644 --- a/secret-rotator/src/main.rs +++ b/secret-rotator/src/main.rs @@ -173,18 +173,28 @@ fn rotate_ha_token() -> Result> { return Err(e); } - // Only now is the old token expendable — the replacement is stored. - match ha.revoke(&old_id) { + // Revoke over a FRESH connection authenticated with the NEW token, not the + // one we've been using. HA binds the WebSocket session to the refresh token + // behind its access token, so deleting the OLD token over the OLD token's + // own session tears that session down before the delete-ack can be read — + // the delete succeeds server-side but surfaces as "connection closed by + // HA", a false failure. Reconnecting with the new token keeps the session + // alive through the revoke. (Verified live: the old flow reported failure + // while the old token was in fact already gone.) + drop(ha); + let mut ha2 = ha::Ha::connect(&ha_url, &fresh)?; + match ha2.revoke(&old_id) { Ok(()) => Ok(format!( "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 - // token that was meant to be retired and is still valid is a real - // finding, so say so loudly. - eprintln!("warning: new token is live, but the OLD token '{old_name}' ({old_id}) REMAINS VALID: {e}"); + // Reaching here means the new token is live and stored but the old + // one may genuinely still be valid. Now that the session is NOT the + // one being deleted, this is a real finding rather than the + // self-inflicted false negative above — surface it loudly. + eprintln!("warning: new token is live and stored, but revoking the OLD token '{old_name}' ({old_id}) failed and it MAY REMAIN VALID: {e}"); Ok(format!( - "rotated {bao_path}#{field}: minted '{client_name}'; old token NOT revoked (see warning)" + "rotated {bao_path}#{field}: minted '{unique_name}'; old token '{old_name}' ({old_id}) NOT confirmed revoked (see warning)" )) } } -- 2.54.0