Skip to content

Commit 08d4226

Browse files
committed
Bind ACME revocation to quarantine
1 parent cf271df commit 08d4226

7 files changed

Lines changed: 233 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ behavior when the change improves security or project direction.
3030
- Journal revocation quarantine phases durably so pre-remote crashes restore the
3131
complete pair, ambiguous remote outcomes stay fail-closed, and confirmed
3232
quarantine survives crashes while permitting replacement issuance.
33+
- Bind revocation to the exact certificate moved into quarantine while holding
34+
the certificate mutation lock, and make the advisory ARI cache recover from
35+
poisoned locks and timestamp overflow without aborting the process.
3336
- Validate online ACME directories structurally and require exact advertised ToS
3437
agreement, with an explicit private-directory override only for omitted terms.
3538
- Parse every advertised ACME endpoint as a bounded HTTPS URI with a real

crates/fluxheim-acme/src/acme_ari.rs

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ struct AriCacheEntry {
1818
refresh_after: SystemTime,
1919
}
2020

21-
static CACHE: std::sync::OnceLock<
22-
std::sync::Mutex<std::collections::HashMap<AriCacheKey, AriCacheEntry>>,
23-
> = std::sync::OnceLock::new();
21+
type AriCache = std::collections::HashMap<AriCacheKey, AriCacheEntry>;
22+
23+
static CACHE: std::sync::OnceLock<std::sync::Mutex<AriCache>> = std::sync::OnceLock::new();
2424

2525
pub(super) async fn execute_due_queue(
2626
config: &Config,
@@ -180,11 +180,7 @@ fn safe_ari_schedule(
180180
}
181181

182182
fn cached_decision(key: &AriCacheKey, now: SystemTime, fallback: bool) -> Option<bool> {
183-
let cache = CACHE.get_or_init(Default::default);
184-
let mut cache = cache.lock().unwrap_or_else(|_| {
185-
log::error!(target: "fluxheim::security", "ACME ARI cache lock poisoned");
186-
std::process::abort();
187-
});
183+
let mut cache = lock_cache();
188184
cache.retain(|_, entry| entry.refresh_after > now);
189185
cache.get(key).map(|entry| {
190186
entry
@@ -194,11 +190,8 @@ fn cached_decision(key: &AriCacheKey, now: SystemTime, fallback: bool) -> Option
194190
}
195191

196192
fn store_cache(key: &AriCacheKey, scheduled: Option<u64>, now: SystemTime, retry_after: Duration) {
197-
let cache = CACHE.get_or_init(Default::default);
198-
let mut cache = cache.lock().unwrap_or_else(|_| {
199-
log::error!(target: "fluxheim::security", "ACME ARI cache lock poisoned");
200-
std::process::abort();
201-
});
193+
let refresh_after = cache_refresh_after(now, retry_after);
194+
let mut cache = lock_cache();
202195
cache.retain(|_, entry| entry.refresh_after > now);
203196
if cache.len() >= MAX_CACHE_ENTRIES && !cache.contains_key(key) {
204197
cache.clear();
@@ -207,12 +200,33 @@ fn store_cache(key: &AriCacheKey, scheduled: Option<u64>, now: SystemTime, retry
207200
key.clone(),
208201
AriCacheEntry {
209202
scheduled_unix_secs: scheduled,
210-
refresh_after: now
211-
+ retry_after.clamp(Duration::from_secs(60), Duration::from_secs(86_400)),
203+
refresh_after,
212204
},
213205
);
214206
}
215207

208+
fn cache_refresh_after(now: SystemTime, retry_after: Duration) -> SystemTime {
209+
now.checked_add(retry_after.clamp(Duration::from_secs(60), Duration::from_secs(86_400)))
210+
.unwrap_or(now)
211+
}
212+
213+
fn lock_cache() -> std::sync::MutexGuard<'static, AriCache> {
214+
let cache = CACHE.get_or_init(Default::default);
215+
match cache.lock() {
216+
Ok(cache) => cache,
217+
Err(poisoned) => {
218+
log::error!(
219+
target: "fluxheim::security",
220+
"ACME ARI advisory cache lock poisoned; discarding cached decisions"
221+
);
222+
let mut recovered = poisoned.into_inner();
223+
recovered.clear();
224+
cache.clear_poison();
225+
recovered
226+
}
227+
}
228+
}
229+
216230
fn unix_secs(time: SystemTime) -> u64 {
217231
time.duration_since(UNIX_EPOCH)
218232
.map(|duration| duration.as_secs())
@@ -283,6 +297,39 @@ mod tests {
283297
assert_eq!(cached_decision(&second, now, false), Some(true));
284298
}
285299

300+
#[test]
301+
fn cache_refresh_deadline_overflow_expires_immediately() {
302+
let near_limit = UNIX_EPOCH
303+
.checked_add(Duration::from_secs(i64::MAX as u64))
304+
.unwrap();
305+
assert_eq!(
306+
cache_refresh_after(near_limit, Duration::from_secs(86_400)),
307+
near_limit
308+
);
309+
}
310+
311+
#[test]
312+
fn poisoned_advisory_cache_is_cleared_and_recovers() {
313+
let _test_lock = CACHE_TEST_LOCK.lock().unwrap();
314+
let cache = CACHE.get_or_init(Default::default);
315+
let poisoned = std::panic::catch_unwind(|| {
316+
let _cache = cache.lock().unwrap();
317+
panic!("poison ARI cache for recovery test");
318+
});
319+
assert!(poisoned.is_err());
320+
assert!(cache.is_poisoned());
321+
322+
let now = UNIX_EPOCH + Duration::from_secs(25_000);
323+
let key = AriCacheKey {
324+
issuer_directory: "https://issuer.example/directory".to_owned(),
325+
certificate_identifier: "recovered-identifier".to_owned(),
326+
};
327+
store_cache(&key, Some(25_100), now, Duration::from_secs(300));
328+
329+
assert!(!cache.is_poisoned());
330+
assert_eq!(cached_decision(&key, now, true), Some(false));
331+
}
332+
286333
#[test]
287334
fn ari_window_must_end_within_certificate_validity() {
288335
let not_after = UNIX_EPOCH + Duration::from_secs(30_000);

crates/fluxheim-acme/src/acme_certificate_revocation.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use super::fs_ops::{CertificateDirectoryFd, rename_certificate_file, sync_direct
44
use super::fs_ops::{
55
ManagedCertificateOwner, certificate_directory, open_safe_certificate_directory, write_new_file,
66
};
7+
#[cfg(feature = "acme-client")]
8+
use super::revocation_fs::read_bounded_regular_file;
79
use super::revocation_fs::{
810
certificate_file_exists_regular, ensure_certificate_slot_absent, ensure_existing_regular_file,
911
revocation_file_names_valid,
@@ -105,6 +107,17 @@ pub(crate) fn begin_managed_certificate_quarantine(
105107

106108
#[cfg(feature = "acme-client")]
107109
impl ManagedCertificateQuarantine {
110+
pub(crate) fn read_quarantined_certificate(
111+
&self,
112+
) -> Result<Vec<u8>, AcmeCertificateInstallError> {
113+
read_bounded_regular_file(
114+
&self.directory,
115+
&self.directory.join(&self.journal.certificate_name),
116+
self.directory_fd(),
117+
MAX_CERTIFICATE_CHAIN_BYTES,
118+
)
119+
}
120+
108121
pub(crate) fn mark_remote_pending(&mut self) -> Result<(), AcmeCertificateInstallError> {
109122
self.update_phase(RevocationPhase::RemotePending)
110123
}

crates/fluxheim-acme/src/acme_certificate_revocation_fs.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,86 @@ pub(crate) fn revocation_file_names_valid(
7777
&& certificate_name == format!(".revoked-{transaction}-fullchain.pem")
7878
&& private_key_name == format!(".revoked-{transaction}-privkey.pem")
7979
}
80+
81+
#[cfg(feature = "acme-client")]
82+
pub(super) fn read_bounded_regular_file(
83+
directory: &Path,
84+
path: &Path,
85+
directory_fd: Option<&CertificateDirectoryFd>,
86+
max_bytes: usize,
87+
) -> Result<Vec<u8>, AcmeCertificateInstallError> {
88+
#[cfg(unix)]
89+
let mut file = {
90+
let directory_fd = directory_fd.ok_or_else(|| AcmeCertificateInstallError::UnsafePath {
91+
path: directory.to_path_buf(),
92+
message: "managed certificate directory fd is unavailable".to_owned(),
93+
})?;
94+
let name = certificate_file_name_in_directory(directory, path)?;
95+
let fd = rustix::fs::openat(
96+
directory_fd,
97+
name,
98+
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC,
99+
rustix::fs::Mode::empty(),
100+
)
101+
.map_err(|error| AcmeCertificateInstallError::Io {
102+
path: path.to_path_buf(),
103+
error: error.into(),
104+
})?;
105+
fs::File::from(fd)
106+
};
107+
#[cfg(not(unix))]
108+
let mut file = {
109+
ensure_existing_regular_file(directory, path, directory_fd)?;
110+
fs::OpenOptions::new()
111+
.read(true)
112+
.open(path)
113+
.map_err(|error| AcmeCertificateInstallError::Io {
114+
path: path.to_path_buf(),
115+
error,
116+
})?
117+
};
118+
let metadata = file
119+
.metadata()
120+
.map_err(|error| AcmeCertificateInstallError::Io {
121+
path: path.to_path_buf(),
122+
error,
123+
})?;
124+
if !metadata.is_file() {
125+
return Err(AcmeCertificateInstallError::UnsafePath {
126+
path: path.to_path_buf(),
127+
message: "managed certificate is not a regular file".to_owned(),
128+
});
129+
}
130+
if metadata.len() > max_bytes as u64 {
131+
return Err(AcmeCertificateInstallError::UnsafePath {
132+
path: path.to_path_buf(),
133+
message: format!("managed certificate exceeds {max_bytes} bytes"),
134+
});
135+
}
136+
let admitted =
137+
usize::try_from(metadata.len()).map_err(|_| AcmeCertificateInstallError::UnsafePath {
138+
path: path.to_path_buf(),
139+
message: format!("managed certificate exceeds {max_bytes} bytes"),
140+
})?;
141+
let mut bytes = vec![0_u8; admitted];
142+
file.read_exact(&mut bytes)
143+
.map_err(|error| AcmeCertificateInstallError::Io {
144+
path: path.to_path_buf(),
145+
error,
146+
})?;
147+
let mut growth_probe = [0_u8; 1];
148+
if file
149+
.read(&mut growth_probe)
150+
.map_err(|error| AcmeCertificateInstallError::Io {
151+
path: path.to_path_buf(),
152+
error,
153+
})?
154+
!= 0
155+
{
156+
return Err(AcmeCertificateInstallError::UnsafePath {
157+
path: path.to_path_buf(),
158+
message: format!("managed certificate exceeds {max_bytes} bytes"),
159+
});
160+
}
161+
Ok(bytes)
162+
}

crates/fluxheim-acme/src/acme_instant_account.rs

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -141,36 +141,34 @@ pub async fn revoke_instant_acme_certificate(
141141
format!("unknown ACME vhost target {vhost_name:?}"),
142142
)
143143
})?;
144-
let certificate_pem = read_bounded_certificate_file(&target.certificate.cert_path)
145-
.map_err(|error| {
144+
let account = load_or_create_instant_acme_account(config, &target.issuer).await?;
145+
let mut quarantine =
146+
begin_managed_certificate_quarantine(&target.certificate).map_err(|error| {
146147
account_error(
147148
&target.issuer,
148-
format!("failed to read managed certificate: {error}"),
149+
format!("failed to quarantine certificate before revocation: {error}"),
149150
)
150-
})?
151-
.ok_or_else(|| account_error(&target.issuer, "managed certificate is missing"))?;
151+
})?;
152+
let certificate_pem = quarantine.read_quarantined_certificate().map_err(|error| {
153+
account_error(
154+
&target.issuer,
155+
format!("failed to read quarantined certificate: {error}"),
156+
)
157+
})?;
152158
use rustls::pki_types::pem::PemObject as _;
153159
let leaf = rustls::pki_types::CertificateDer::pem_slice_iter(&certificate_pem)
154160
.next()
155161
.transpose()
156162
.map_err(|error| {
157163
account_error(
158164
&target.issuer,
159-
format!("failed to parse managed certificate: {error}"),
165+
format!("failed to parse quarantined certificate: {error}"),
160166
)
161167
})?
162168
.ok_or_else(|| {
163169
account_error(
164170
&target.issuer,
165-
"managed certificate has no leaf certificate",
166-
)
167-
})?;
168-
let account = load_or_create_instant_acme_account(config, &target.issuer).await?;
169-
let mut quarantine =
170-
begin_managed_certificate_quarantine(&target.certificate).map_err(|error| {
171-
account_error(
172-
&target.issuer,
173-
format!("failed to quarantine certificate before revocation: {error}"),
171+
"quarantined certificate has no leaf certificate",
174172
)
175173
})?;
176174
quarantine.mark_remote_pending().map_err(|error| {

crates/fluxheim-acme/src/acme_tests_lifecycle.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,59 @@ fn dropped_revocation_quarantine_restores_the_complete_pair() {
9292
);
9393
}
9494

95+
#[cfg(feature = "acme-client")]
96+
#[test]
97+
fn revocation_reads_quarantined_identity_while_renewal_waits() {
98+
let storage = fluxheim_common::test_support::unique_temp_path("acme-revoke-renew-race");
99+
let (certificate_a, key_a) = issued_material_for(&["example.test"]);
100+
let paths = install_managed_certificate(
101+
&storage,
102+
"example",
103+
certificate_a.as_bytes(),
104+
key_a.as_bytes(),
105+
&["example.test".to_owned()],
106+
)
107+
.unwrap();
108+
let quarantine = crate::begin_managed_certificate_quarantine(&paths).unwrap();
109+
110+
assert_eq!(
111+
quarantine.read_quarantined_certificate().unwrap(),
112+
certificate_a.as_bytes()
113+
);
114+
115+
let (certificate_b, key_b) = issued_material_for(&["example.test"]);
116+
let (attempting_tx, attempting_rx) = std::sync::mpsc::channel();
117+
let (completed_tx, completed_rx) = std::sync::mpsc::channel();
118+
let renewal_storage = storage.clone();
119+
let renewal = std::thread::spawn(move || {
120+
attempting_tx.send(()).unwrap();
121+
completed_tx
122+
.send(install_managed_certificate(
123+
&renewal_storage,
124+
"example",
125+
certificate_b.as_bytes(),
126+
key_b.as_bytes(),
127+
&["example.test".to_owned()],
128+
))
129+
.unwrap();
130+
});
131+
132+
attempting_rx
133+
.recv_timeout(std::time::Duration::from_secs(2))
134+
.unwrap();
135+
assert!(
136+
completed_rx
137+
.recv_timeout(std::time::Duration::from_millis(100))
138+
.is_err()
139+
);
140+
quarantine.rollback().unwrap();
141+
completed_rx
142+
.recv_timeout(std::time::Duration::from_secs(2))
143+
.unwrap()
144+
.unwrap();
145+
renewal.join().unwrap();
146+
}
147+
95148
fn installed_revocation_test_pair(label: &str) -> (std::path::PathBuf, AcmeCertificatePaths) {
96149
let storage = fluxheim_common::test_support::unique_temp_path(label);
97150
let paths = install_managed_certificate(

docs/certificate-renewal.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,9 @@ operators can see this capability limit before an incident.
281281

282282
Revocation remains available for managed targets even when automatic renewal is
283283
disabled. Fluxheim first quarantines the certificate and key atomically under
284-
the same mutation lock used by TLS readers. A durable fixed-name journal records
284+
the same mutation lock used by TLS readers, then reads and submits the exact
285+
quarantined certificate while retaining that lock so concurrent renewal cannot
286+
substitute another identity. A durable fixed-name journal records
285287
prepared, pair-quarantined, remote-pending, and remote-confirmed phases with
286288
validated transaction-derived filenames. A crash before remote contact restores
287289
the complete pair; an ambiguous remote-pending outcome remains quarantined for
@@ -682,6 +684,9 @@ ARI planning uses four concurrent lookups, a 10-second deadline per target, and
682684
a 30-second planning budget, executes due renewals progressively, and caches
683685
successful or unsupported responses until a bounded `Retry-After` deadline.
684686
Cache entries are namespaced by issuer directory plus certificate identifier.
687+
Refresh deadlines use checked arithmetic, and a poisoned advisory-cache lock
688+
causes cached decisions to be discarded and rebuilt instead of terminating the
689+
process.
685690
ARI can never postpone renewal inside the final seven days of certificate
686691
validity, and windows extending beyond `notAfter` are rejected. The cache is
687692
process-local: external timer invocations retain the concurrency and time

0 commit comments

Comments
 (0)