Skip to content

Commit cf271df

Browse files
committed
Close ACME recovery edge cases
1 parent 1287bc2 commit cf271df

15 files changed

Lines changed: 918 additions & 259 deletions

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,20 @@ behavior when the change improves security or project direction.
2020
with race coverage proving cleanup cannot leave a partial certificate pair.
2121
- Bound ARI planning by lookup concurrency, per-target timeout, and total budget;
2222
execute due work progressively and cache issuer guidance through Retry-After.
23+
- Namespace ARI cache entries by issuer and certificate identity, force renewal
24+
inside the seven-day emergency window, and reject issuer windows extending
25+
beyond certificate validity.
2326
- Make account deactivation and certificate revocation transactional. Pending
2427
account state fails closed, while revocation quarantines the active pair before
25-
the remote operation, restores it on failure, and requests live reload after
26-
success even when scheduled renewal is disabled.
28+
the remote operation, preserves ambiguous outcomes for operator resolution,
29+
and requests live reload after success even when scheduled renewal is disabled.
30+
- Journal revocation quarantine phases durably so pre-remote crashes restore the
31+
complete pair, ambiguous remote outcomes stay fail-closed, and confirmed
32+
quarantine survives crashes while permitting replacement issuance.
2733
- Validate online ACME directories structurally and require exact advertised ToS
2834
agreement, with an explicit private-directory override only for omitted terms.
35+
- Parse every advertised ACME endpoint as a bounded HTTPS URI with a real
36+
authority, and expose unavailable safe account rollover in doctor output.
2937
- Remove the unmaintained direct `rustls-pemfile` dependency in favor of the
3038
maintained `rustls-pki-types` PEM parser already provided through rustls.
3139

crates/fluxheim-acme/src/acme_ari.rs

Lines changed: 103 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ const LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
44
const PLANNING_BUDGET: Duration = Duration::from_secs(30);
55
const LOOKUP_CONCURRENCY: usize = 4;
66
const MAX_CACHE_ENTRIES: usize = 4096;
7+
const EMERGENCY_RENEWAL_WINDOW: Duration = Duration::from_secs(7 * 24 * 60 * 60);
8+
9+
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
10+
struct AriCacheKey {
11+
issuer_directory: String,
12+
certificate_identifier: String,
13+
}
714

815
#[derive(Clone, Copy)]
916
struct AriCacheEntry {
@@ -12,7 +19,7 @@ struct AriCacheEntry {
1219
}
1320

1421
static CACHE: std::sync::OnceLock<
15-
std::sync::Mutex<std::collections::HashMap<String, AriCacheEntry>>,
22+
std::sync::Mutex<std::collections::HashMap<AriCacheKey, AriCacheEntry>>,
1623
> = std::sync::OnceLock::new();
1724

1825
pub(super) async fn execute_due_queue(
@@ -88,7 +95,13 @@ async fn allows_renewal_now(config: &Config, item: &AcmeRenewalItem, now: System
8895
let Ok(identifier) = instant_acme::CertificateIdentifier::try_from(&leaf) else {
8996
return fallback;
9097
};
91-
let cache_key = identifier.to_string();
98+
if emergency_renewal_required(item.not_after, now) {
99+
return true;
100+
}
101+
let cache_key = AriCacheKey {
102+
issuer_directory: issuer.directory_url.clone(),
103+
certificate_identifier: identifier.to_string(),
104+
};
92105
if let Some(decision) = cached_decision(&cache_key, now, fallback) {
93106
return decision;
94107
}
@@ -133,15 +146,40 @@ async fn allows_renewal_now(config: &Config, item: &AcmeRenewalItem, now: System
133146
};
134147
let start = info.suggested_window.start.unix_timestamp();
135148
let end = info.suggested_window.end.unix_timestamp();
136-
if end <= start {
149+
let Some(scheduled) = safe_ari_schedule(item.not_after, leaf.as_ref(), start, end) else {
150+
log::warn!(
151+
target: "fluxheim::security",
152+
"issuer {} returned an ACME ARI window outside certificate validity for vhost {}",
153+
item.target.issuer,
154+
item.target.vhost_name
155+
);
137156
return fallback;
138-
}
139-
let scheduled = deterministic_renewal_time(leaf.as_ref(), start, end).max(0) as u64;
157+
};
140158
store_cache(&cache_key, Some(scheduled), now, retry_after);
141159
unix_secs(now) >= scheduled
142160
}
143161

144-
fn cached_decision(key: &str, now: SystemTime, fallback: bool) -> Option<bool> {
162+
fn emergency_renewal_required(not_after: Option<SystemTime>, now: SystemTime) -> bool {
163+
let Some(emergency_deadline) = now.checked_add(EMERGENCY_RENEWAL_WINDOW) else {
164+
return true;
165+
};
166+
not_after.is_none_or(|not_after| not_after <= emergency_deadline)
167+
}
168+
169+
fn safe_ari_schedule(
170+
not_after: Option<SystemTime>,
171+
certificate_der: &[u8],
172+
start: i64,
173+
end: i64,
174+
) -> Option<u64> {
175+
let not_after = not_after?.duration_since(UNIX_EPOCH).ok()?.as_secs();
176+
if start < 0 || end <= start || end as u64 > not_after {
177+
return None;
178+
}
179+
Some(deterministic_renewal_time(certificate_der, start, end) as u64)
180+
}
181+
182+
fn cached_decision(key: &AriCacheKey, now: SystemTime, fallback: bool) -> Option<bool> {
145183
let cache = CACHE.get_or_init(Default::default);
146184
let mut cache = cache.lock().unwrap_or_else(|_| {
147185
log::error!(target: "fluxheim::security", "ACME ARI cache lock poisoned");
@@ -155,7 +193,7 @@ fn cached_decision(key: &str, now: SystemTime, fallback: bool) -> Option<bool> {
155193
})
156194
}
157195

158-
fn store_cache(key: &str, scheduled: Option<u64>, now: SystemTime, retry_after: Duration) {
196+
fn store_cache(key: &AriCacheKey, scheduled: Option<u64>, now: SystemTime, retry_after: Duration) {
159197
let cache = CACHE.get_or_init(Default::default);
160198
let mut cache = cache.lock().unwrap_or_else(|_| {
161199
log::error!(target: "fluxheim::security", "ACME ARI cache lock poisoned");
@@ -166,7 +204,7 @@ fn store_cache(key: &str, scheduled: Option<u64>, now: SystemTime, retry_after:
166204
cache.clear();
167205
}
168206
cache.insert(
169-
key.to_owned(),
207+
key.clone(),
170208
AriCacheEntry {
171209
scheduled_unix_secs: scheduled,
172210
refresh_after: now
@@ -196,6 +234,8 @@ fn deterministic_renewal_time(certificate_der: &[u8], start: i64, end: i64) -> i
196234
mod tests {
197235
use super::*;
198236

237+
static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
238+
199239
#[test]
200240
fn schedule_is_stable_and_inside_suggested_window() {
201241
let first = deterministic_renewal_time(b"certificate", 100, 200);
@@ -206,22 +246,68 @@ mod tests {
206246

207247
#[test]
208248
fn cache_honors_retry_after_and_schedule() {
249+
let _test_lock = CACHE_TEST_LOCK.lock().unwrap();
209250
let now = UNIX_EPOCH + Duration::from_secs(10_000);
210-
store_cache(
211-
"test-cache-key",
212-
Some(10_100),
213-
now,
214-
Duration::from_secs(300),
215-
);
216-
assert_eq!(cached_decision("test-cache-key", now, true), Some(false));
251+
let key = AriCacheKey {
252+
issuer_directory: "https://issuer.example/directory".to_owned(),
253+
certificate_identifier: "identifier".to_owned(),
254+
};
255+
store_cache(&key, Some(10_100), now, Duration::from_secs(300));
256+
assert_eq!(cached_decision(&key, now, true), Some(false));
217257
assert_eq!(
218-
cached_decision("test-cache-key", now + Duration::from_secs(100), false),
258+
cached_decision(&key, now + Duration::from_secs(100), false),
219259
Some(true)
220260
);
221261
assert_eq!(
222-
cached_decision("test-cache-key", now + Duration::from_secs(301), false),
262+
cached_decision(&key, now + Duration::from_secs(301), false),
263+
None
264+
);
265+
}
266+
267+
#[test]
268+
fn cache_is_namespaced_by_issuer_directory() {
269+
let _test_lock = CACHE_TEST_LOCK.lock().unwrap();
270+
let now = UNIX_EPOCH + Duration::from_secs(20_000);
271+
let first = AriCacheKey {
272+
issuer_directory: "https://first.example/directory".to_owned(),
273+
certificate_identifier: "same-aki-and-serial".to_owned(),
274+
};
275+
let second = AriCacheKey {
276+
issuer_directory: "https://second.example/directory".to_owned(),
277+
certificate_identifier: "same-aki-and-serial".to_owned(),
278+
};
279+
store_cache(&first, Some(20_100), now, Duration::from_secs(300));
280+
store_cache(&second, Some(19_900), now, Duration::from_secs(300));
281+
282+
assert_eq!(cached_decision(&first, now, true), Some(false));
283+
assert_eq!(cached_decision(&second, now, false), Some(true));
284+
}
285+
286+
#[test]
287+
fn ari_window_must_end_within_certificate_validity() {
288+
let not_after = UNIX_EPOCH + Duration::from_secs(30_000);
289+
assert_eq!(
290+
safe_ari_schedule(Some(not_after), b"certificate", 29_000, 30_001),
223291
None
224292
);
293+
assert!(safe_ari_schedule(Some(not_after), b"certificate", 29_000, 30_000).is_some());
294+
}
295+
296+
#[test]
297+
fn expired_and_emergency_window_certificates_renew_immediately() {
298+
let now = UNIX_EPOCH + Duration::from_secs(40_000);
299+
assert!(emergency_renewal_required(
300+
Some(now - Duration::from_secs(1)),
301+
now
302+
));
303+
assert!(emergency_renewal_required(
304+
Some(now + EMERGENCY_RENEWAL_WINDOW),
305+
now
306+
));
307+
assert!(!emergency_renewal_required(
308+
Some(now + EMERGENCY_RENEWAL_WINDOW + Duration::from_secs(1)),
309+
now
310+
));
225311
}
226312

227313
#[test]

0 commit comments

Comments
 (0)