@@ -4,6 +4,13 @@ const LOOKUP_TIMEOUT: Duration = Duration::from_secs(10);
44const PLANNING_BUDGET : Duration = Duration :: from_secs ( 30 ) ;
55const LOOKUP_CONCURRENCY : usize = 4 ;
66const 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 ) ]
916struct AriCacheEntry {
@@ -12,7 +19,7 @@ struct AriCacheEntry {
1219}
1320
1421static 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
1825pub ( 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
196234mod 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