use crate::model_relay::preflight::{CA_EXPIRING_PREFIX, CA_REASON_MARKER};
pub const REFUSED_PREFIX: &str = "refused by the agent on ";
pub fn refused_reason(host: &str) -> String {
format!("{CA_REASON_MARKER}{REFUSED_PREFIX}{host}")
}
pub fn expiring_reason(not_after: time::OffsetDateTime) -> String {
format!("{CA_REASON_MARKER}{CA_EXPIRING_PREFIX}{}", not_after.date())
}
pub fn prev_dir(openlatch_dir: &std::path::Path) -> std::path::PathBuf {
crate::model_relay::ca::ca_dir(openlatch_dir).with_file_name("ca.prev")
}
pub fn staging_dir(openlatch_dir: &std::path::Path) -> std::path::PathBuf {
crate::model_relay::ca::ca_dir(openlatch_dir).with_file_name("ca.next")
}
pub fn days_left(info: &crate::model_relay::ca::CaInfo, now: time::OffsetDateTime) -> i64 {
(info.not_after - now).whole_days()
}
#[derive(Debug, PartialEq, Eq)]
pub enum RotateOutcome {
NotDue,
Rotated { old_sha: String, new_sha: String },
InstallDeclined(String),
ProofFailed(String),
PromotedButStuck {
old_sha: String,
new_sha: String,
why: String,
},
Failed(String),
}
pub fn rotate(
openlatch_dir: &std::path::Path,
store: &dyn crate::model_relay::trust_store::TrustStore,
prove: &dyn Fn(&std::path::Path) -> Result<(), String>, now: time::OffsetDateTime,
) -> RotateOutcome {
use crate::model_relay::{ca, trust_store::InstallOutcome};
let live = ca::ca_dir(openlatch_dir);
let Some(old) = ca::inspect(&live) else {
return RotateOutcome::Failed("no CA on disk".into());
};
if let Err(why) = retire_prev(openlatch_dir, store) {
return RotateOutcome::Failed(why);
}
if days_left(&old, now) > ca::CA_WARN_DAYS {
return RotateOutcome::NotDue;
}
let staging = staging_dir(openlatch_dir);
if let Some(leftover) = ca::inspect(&staging) {
let retired = store.remove(&leftover.sha256_hex).is_ok()
&& matches!(store.is_trusted(&leftover.sha256_hex), Ok(false));
if !retired {
return RotateOutcome::Failed(format!(
"a stale staged certificate authority (SHA-256 {}) is still trusted in your user \
store; its removal was refused — run `openlatch doctor --fix` again",
leftover.sha256_hex
));
}
}
let _ = ca::remove(&staging); let new = match ca::LocalCa::generate_into(&staging) {
Ok(ca) => ca,
Err(e) => return RotateOutcome::Failed(e.message),
};
let new_sha = new.sha256_hex();
match store.install(&ca::ca_pem_path(&staging), &new_sha) {
InstallOutcome::Installed | InstallOutcome::AlreadyTrusted => {}
InstallOutcome::NoGuiSession => {
let _ = ca::remove(&staging);
return RotateOutcome::InstallDeclined(
"no desktop session to confirm the install".into(),
);
}
InstallOutcome::Refused(why) => {
let _ = ca::remove(&staging);
return RotateOutcome::InstallDeclined(why);
}
}
if let Err(why) = prove(&staging) {
let retired =
store.remove(&new_sha).is_ok() && matches!(store.is_trusted(&new_sha), Ok(false));
if retired {
let _ = ca::remove(&staging);
return RotateOutcome::ProofFailed(why);
}
return RotateOutcome::ProofFailed(format!(
"{why}; the new certificate authority (SHA-256 {new_sha}) is still trusted in your \
user store — its removal was refused, so ca.next/ was kept for retry"
));
}
let prev = prev_dir(openlatch_dir); let _ = ca::remove(&prev); if let Err(e) = std::fs::rename(&live, &prev) {
return RotateOutcome::Failed(e.to_string());
}
if let Err(e) = std::fs::rename(&staging, &live) {
let _ = std::fs::rename(&prev, &live); return RotateOutcome::Failed(e.to_string());
}
let old_gone =
store.remove(&old.sha256_hex).is_ok() && !store.is_trusted(&old.sha256_hex).unwrap_or(true);
if !old_gone {
let why = format!(
"rotated to {new_sha}, but the previous certificate authority is still trusted; run `openlatch doctor --fix` again"
);
return RotateOutcome::PromotedButStuck {
old_sha: old.sha256_hex,
new_sha,
why,
};
}
let _ = ca::remove(&prev);
RotateOutcome::Rotated {
old_sha: old.sha256_hex,
new_sha,
}
}
pub fn retire_prev(
openlatch_dir: &std::path::Path,
store: &dyn crate::model_relay::trust_store::TrustStore,
) -> Result<(), String> {
use crate::model_relay::ca;
let Some(prev_info) = ca::inspect(&prev_dir(openlatch_dir)) else {
return Ok(());
};
let _ = store.remove(&prev_info.sha256_hex);
if store.is_trusted(&prev_info.sha256_hex).unwrap_or(true) {
return Err(
"a previous certificate authority is still trusted; its removal was refused".into(),
);
}
let _ = ca::remove(&prev_dir(openlatch_dir));
Ok(())
}
pub async fn prove_with_ephemeral_relay(
cfg: &crate::config::Config,
state: crate::model_relay::ModelRelayState,
staging: &std::path::Path,
targets: &[(crate::model_relay::wire_format::WireFormat, &'static str)],
store: &dyn crate::model_relay::trust_store::TrustStore,
) -> Result<(), String> {
use crate::model_relay::{ca, preflight};
let hosts: Vec<&'static str> = targets.iter().map(|(_, h)| *h).collect();
let interceptor = std::sync::Arc::new(
ca::Interceptor::new(staging, hosts.iter().copied()).map_err(|e| e.message)?,
);
if let Ok(mut slot) = state.intercept.write() {
*slot = Some(interceptor.clone());
}
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let app = crate::model_relay::router(std::sync::Arc::new(state));
let server = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let mut result = Ok(());
for (fmt, host) in targets {
let upstream = format!("https://{host}");
result = preflight::probe_intercept(
cfg,
port,
*fmt,
&upstream,
&ca::ca_pem_path(staging),
host,
preflight::PREFLIGHT_TIMEOUT,
)
.await;
if result.is_ok() {
result = preflight::prove_store(store, &interceptor, host); }
if result.is_err() {
break;
}
}
server.abort();
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_relay::trust_store::test_support::FakeStore;
#[test]
fn a_refused_new_root_removal_keeps_ca_next_and_says_so() {
let tmp = tempfile::tempdir().expect("tempdir");
let old = crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(
tmp.path(),
))
.expect("generate old");
let store = FakeStore::default();
store.trust(&old.sha256_hex());
let now = old.not_after() - time::Duration::days(20);
let outcome = rotate(
tmp.path(),
&store,
&|staging| {
let new = crate::model_relay::ca::inspect(staging).expect("staged CA readable");
store.refuse_removal_of(&new.sha256_hex);
Err("forced proof failure (fixture)".to_string())
},
now,
);
match &outcome {
RotateOutcome::ProofFailed(why) => {
assert!(why.contains("forced proof failure"), "{why}");
assert!(
why.to_lowercase().contains("trusted"),
"must note the new root is still trusted: {why}"
);
}
other => panic!("expected ProofFailed, got {other:?}"),
}
assert!(
staging_dir(tmp.path()).exists(),
"ca.next must be KEPT — its removal from the store was refused"
);
let live = crate::model_relay::ca::inspect(&crate::model_relay::ca::ca_dir(tmp.path()))
.expect("ca.pem must exist");
assert_eq!(
live.sha256_hex,
old.sha256_hex(),
"the old CA is still live"
);
}
#[test]
fn a_trusted_leftover_ca_next_is_kept_when_its_removal_is_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let old = crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(
tmp.path(),
))
.expect("generate old");
let leftover = crate::model_relay::ca::LocalCa::generate_into(&staging_dir(tmp.path()))
.expect("generate leftover ca.next");
let store = FakeStore::default();
store.trust(&old.sha256_hex());
store.trust(&leftover.sha256_hex());
store.refuse_removal_of(&leftover.sha256_hex());
let now = old.not_after() - time::Duration::days(20);
let outcome = rotate(
tmp.path(),
&store,
&|_| panic!("must never reach proof — the leftover check must fail first"),
now,
);
match &outcome {
RotateOutcome::Failed(why) => {
assert!(why.to_lowercase().contains("trusted"), "{why}");
}
other => panic!("expected Failed, got {other:?}"),
}
assert!(
staging_dir(tmp.path()).exists(),
"the leftover ca.next must be KEPT, not silently wiped"
);
assert!(
store.trusted_directly(&leftover.sha256_hex()),
"still trusted, per the fixture"
);
let live = crate::model_relay::ca::inspect(&crate::model_relay::ca::ca_dir(tmp.path()))
.expect("ca.pem must exist");
assert_eq!(
live.sha256_hex,
old.sha256_hex(),
"the live CA is untouched"
);
}
}