Skip to content

Commit d0eb4a5

Browse files
committed
Harden ACME account store interfaces
1 parent bbfd760 commit d0eb4a5

11 files changed

Lines changed: 322 additions & 13 deletions

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,13 @@ behavior when the change improves security or project direction.
3939
API patch preserves configured contacts and EAB with caller-provided keys.
4040
- Keep blocking account files and locks off Tokio workers. Async ACME paths use
4141
nonblocking lifecycle-lock attempts on blocking workers, bounded asynchronous
42-
retry, contention diagnostics, and a 10-second lock-wait deadline.
42+
retry, contention diagnostics, and a 10-second lock-wait deadline. Public
43+
bounded async credential load, store, and removal APIs prevent downstream
44+
Tokio callers from falling back to indefinitely blocking lifecycle methods.
4345
- Verify the vendored `instant-acme` source against published `0.8.5` hashes in
44-
CI and release gates after removing the single marked downstream API patch.
46+
CI and release gates after removing the single marked downstream API patch;
47+
separately pin the exact permitted patch body and use a private unpredictable
48+
validation workspace to prevent local symlink clobbering.
4549
- Validate online ACME directories structurally and require exact advertised ToS
4650
agreement, with an explicit private-directory override only for omitted terms.
4751
- Parse every advertised ACME endpoint as a bounded HTTPS URI with a real

crates/fluxheim-acme/src/acme_account_async.rs

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,156 @@
11
use super::*;
2+
use std::sync::Arc;
23

34
const ACCOUNT_LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
45
const ACCOUNT_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25);
56

6-
pub(crate) async fn load_account_credentials_async(
7+
/// Loads ACME credentials without blocking a Tokio worker.
8+
///
9+
/// Returns an error when the issuer lifecycle lock cannot be acquired within
10+
/// Fluxheim's bounded account-lock deadline.
11+
pub async fn load_account_credentials_async(
712
storage: &Path,
813
issuer_name: &str,
914
) -> Result<Option<instant_acme::AccountCredentials>, AcmeInstantClientError> {
1015
load_account_credentials_async_with_timeout(storage, issuer_name, ACCOUNT_LOCK_WAIT_TIMEOUT)
1116
.await
1217
}
1318

19+
/// Persists ACME credentials without blocking a Tokio worker.
20+
///
21+
/// Returns an error when the issuer lifecycle lock cannot be acquired within
22+
/// Fluxheim's bounded account-lock deadline.
23+
pub async fn store_account_credentials_async(
24+
storage: &Path,
25+
issuer_name: &str,
26+
credentials: instant_acme::AccountCredentials,
27+
) -> Result<AcmeAccountCredentialsPath, AcmeInstantClientError> {
28+
store_account_credentials_async_with_timeout(
29+
storage,
30+
issuer_name,
31+
credentials,
32+
ACCOUNT_LOCK_WAIT_TIMEOUT,
33+
)
34+
.await
35+
}
36+
37+
async fn store_account_credentials_async_with_timeout(
38+
storage: &Path,
39+
issuer_name: &str,
40+
credentials: instant_acme::AccountCredentials,
41+
wait_timeout: Duration,
42+
) -> Result<AcmeAccountCredentialsPath, AcmeInstantClientError> {
43+
let storage = storage.to_path_buf();
44+
let issuer = issuer_name.to_owned();
45+
let credentials = Arc::new(credentials);
46+
run_account_store_attempt(issuer_name, "credential store", wait_timeout, move || {
47+
try_store_account_credentials(&storage, &issuer, credentials.as_ref())
48+
})
49+
.await
50+
}
51+
52+
/// Removes ACME credentials without blocking a Tokio worker.
53+
///
54+
/// Returns an error when the issuer lifecycle lock cannot be acquired within
55+
/// Fluxheim's bounded account-lock deadline.
56+
pub async fn remove_account_credentials_async(
57+
storage: &Path,
58+
issuer_name: &str,
59+
) -> Result<bool, AcmeInstantClientError> {
60+
remove_account_credentials_async_with_timeout(storage, issuer_name, ACCOUNT_LOCK_WAIT_TIMEOUT)
61+
.await
62+
}
63+
64+
async fn remove_account_credentials_async_with_timeout(
65+
storage: &Path,
66+
issuer_name: &str,
67+
wait_timeout: Duration,
68+
) -> Result<bool, AcmeInstantClientError> {
69+
let storage = storage.to_path_buf();
70+
let issuer = issuer_name.to_owned();
71+
run_account_store_attempt(issuer_name, "credential removal", wait_timeout, move || {
72+
try_remove_account_credentials(&storage, &issuer)
73+
})
74+
.await
75+
}
76+
77+
fn try_store_account_credentials(
78+
storage: &Path,
79+
issuer_name: &str,
80+
credentials: &instant_acme::AccountCredentials,
81+
) -> Result<AccountStoreAttempt<AcmeAccountCredentialsPath>, AcmeAccountStoreError> {
82+
let credentials_path = account_credentials_path(storage, issuer_name);
83+
let directory = account_directory(&credentials_path)?;
84+
ensure_safe_account_directory(directory)?;
85+
let Some(_lock) = AcmeMutationLock::try_acquire(directory).map_err(|error| {
86+
account_async_store_io_error(&directory.join(".fluxheim-acme.lock"), error)
87+
})?
88+
else {
89+
return Ok(AccountStoreAttempt::Contended);
90+
};
91+
let pending = directory.join(".credentials.bootstrap.pending");
92+
if pending
93+
.try_exists()
94+
.map_err(|error| account_async_store_io_error(&pending, error))?
95+
{
96+
return Err(AcmeAccountStoreError::UnsafePath {
97+
path: pending,
98+
message: "account bootstrap is pending; credentials must be promoted by the bootstrap transaction"
99+
.to_owned(),
100+
});
101+
}
102+
store_account_credentials_locked(&credentials_path, directory, credentials)
103+
.map(AccountStoreAttempt::Acquired)
104+
}
105+
106+
fn try_remove_account_credentials(
107+
storage: &Path,
108+
issuer_name: &str,
109+
) -> Result<AccountStoreAttempt<bool>, AcmeAccountStoreError> {
110+
let credentials_path = account_credentials_path(storage, issuer_name);
111+
let directory = account_directory(&credentials_path)?;
112+
ensure_safe_account_directory(directory)?;
113+
let Some(_lock) = AcmeMutationLock::try_acquire(directory).map_err(|error| {
114+
account_async_store_io_error(&directory.join(".fluxheim-acme.lock"), error)
115+
})?
116+
else {
117+
return Ok(AccountStoreAttempt::Contended);
118+
};
119+
ensure_safe_account_destination(&credentials_path.path)?;
120+
let removed = match fs::remove_file(&credentials_path.path) {
121+
Ok(()) => {
122+
let directory_file = fs::File::open(directory)
123+
.map_err(|error| account_async_store_io_error(directory, error))?;
124+
directory_file
125+
.sync_all()
126+
.map_err(|error| account_async_store_io_error(directory, error))?;
127+
true
128+
}
129+
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
130+
Err(error) => return Err(account_async_store_io_error(&credentials_path.path, error)),
131+
};
132+
Ok(AccountStoreAttempt::Acquired(removed))
133+
}
134+
135+
fn account_directory(
136+
credentials_path: &AcmeAccountCredentialsPath,
137+
) -> Result<&Path, AcmeAccountStoreError> {
138+
credentials_path
139+
.path
140+
.parent()
141+
.ok_or_else(|| AcmeAccountStoreError::UnsafePath {
142+
path: credentials_path.path.clone(),
143+
message: "credentials path has no parent directory".to_owned(),
144+
})
145+
}
146+
147+
fn account_async_store_io_error(path: &Path, error: io::Error) -> AcmeAccountStoreError {
148+
AcmeAccountStoreError::Io {
149+
path: path.to_path_buf(),
150+
error,
151+
}
152+
}
153+
14154
async fn load_account_credentials_async_with_timeout(
15155
storage: &Path,
16156
issuer_name: &str,
@@ -169,3 +309,23 @@ pub(crate) async fn load_account_credentials_with_test_timeout(
169309
) -> Result<Option<instant_acme::AccountCredentials>, AcmeInstantClientError> {
170310
load_account_credentials_async_with_timeout(storage, issuer_name, wait_timeout).await
171311
}
312+
313+
#[cfg(test)]
314+
pub(crate) async fn store_account_credentials_with_test_timeout(
315+
storage: &Path,
316+
issuer_name: &str,
317+
credentials: instant_acme::AccountCredentials,
318+
wait_timeout: Duration,
319+
) -> Result<AcmeAccountCredentialsPath, AcmeInstantClientError> {
320+
store_account_credentials_async_with_timeout(storage, issuer_name, credentials, wait_timeout)
321+
.await
322+
}
323+
324+
#[cfg(test)]
325+
pub(crate) async fn remove_account_credentials_with_test_timeout(
326+
storage: &Path,
327+
issuer_name: &str,
328+
wait_timeout: Duration,
329+
) -> Result<bool, AcmeInstantClientError> {
330+
remove_account_credentials_async_with_timeout(storage, issuer_name, wait_timeout).await
331+
}

crates/fluxheim-acme/src/acme_account_store.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ pub fn account_credentials_path(storage: &Path, issuer_name: &str) -> AcmeAccoun
4949
}
5050
}
5151

52+
/// Loads persisted ACME account credentials.
53+
///
54+
/// # Blocking
55+
///
56+
/// Waits indefinitely for the issuer lifecycle lock. Async code should use
57+
/// `load_account_credentials_async` instead.
5258
pub fn load_account_credentials(
5359
storage: &Path,
5460
issuer_name: &str,
@@ -276,6 +282,12 @@ impl AccountDeactivationTransaction {
276282
}
277283
}
278284

285+
/// Persists ACME account credentials atomically.
286+
///
287+
/// # Blocking
288+
///
289+
/// Waits indefinitely for the issuer lifecycle lock. Async code should use
290+
/// `store_account_credentials_async` instead.
279291
pub fn store_account_credentials(
280292
storage: &Path,
281293
issuer_name: &str,
@@ -307,7 +319,7 @@ pub fn store_account_credentials(
307319
store_account_credentials_locked(&credentials_path, directory, credentials)
308320
}
309321

310-
fn store_account_credentials_locked(
322+
pub(crate) fn store_account_credentials_locked(
311323
credentials_path: &AcmeAccountCredentialsPath,
312324
directory: &Path,
313325
credentials: &instant_acme::AccountCredentials,
@@ -345,6 +357,12 @@ fn store_account_credentials_locked(
345357
Ok(credentials_path.clone())
346358
}
347359

360+
/// Removes persisted ACME account credentials.
361+
///
362+
/// # Blocking
363+
///
364+
/// Waits indefinitely for the issuer lifecycle lock. Async code should use
365+
/// `remove_account_credentials_async` instead.
348366
pub fn remove_account_credentials(
349367
storage: &Path,
350368
issuer_name: &str,
@@ -372,7 +390,7 @@ pub fn remove_account_credentials(
372390
}
373391
}
374392

375-
fn ensure_safe_account_directory(directory: &Path) -> Result<(), AcmeAccountStoreError> {
393+
pub(crate) fn ensure_safe_account_directory(directory: &Path) -> Result<(), AcmeAccountStoreError> {
376394
reject_existing_symlink_in_account_path(directory)?;
377395
fs::create_dir_all(directory).map_err(|error| account_store_io_error(directory, error))?;
378396
reject_existing_symlink_in_account_path(directory)?;
@@ -388,7 +406,7 @@ fn ensure_safe_account_directory(directory: &Path) -> Result<(), AcmeAccountStor
388406
Ok(())
389407
}
390408

391-
fn ensure_safe_account_destination(path: &Path) -> Result<(), AcmeAccountStoreError> {
409+
pub(crate) fn ensure_safe_account_destination(path: &Path) -> Result<(), AcmeAccountStoreError> {
392410
match fs::symlink_metadata(path) {
393411
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
394412
Err(AcmeAccountStoreError::UnsafePath {

crates/fluxheim-acme/src/acme_tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ mod plan;
4141
mod renewal;
4242
#[path = "acme_tests_storage.rs"]
4343
mod storage;
44+
#[cfg(feature = "acme-client")]
45+
#[path = "acme_tests_storage_async.rs"]
46+
mod storage_async;
4447

4548
fn acme_config_with_vhosts(vhosts: Vec<VhostConfig>) -> Config {
4649
Config {

crates/fluxheim-acme/src/acme_tests_account_bootstrap.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,53 @@ fn async_account_lock_contention_has_a_bounded_deadline() {
142142
drop(pending);
143143
}
144144

145+
#[test]
146+
fn async_account_mutations_remain_responsive_and_time_out_under_contention() {
147+
let storage = fluxheim_common::test_support::unique_temp_path("acme-account-async-mutations");
148+
let pending =
149+
match begin_account_bootstrap(&storage, "issuer", "https://acme.example.test/directory")
150+
.unwrap()
151+
{
152+
AccountBootstrap::Pending(pending) => pending,
153+
AccountBootstrap::Existing(_) => panic!("expected a pending account bootstrap"),
154+
};
155+
let runtime = tokio::runtime::Builder::new_current_thread()
156+
.enable_all()
157+
.build()
158+
.unwrap();
159+
let heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
160+
let started = std::time::Instant::now();
161+
162+
runtime.block_on(async {
163+
let store = crate::acme_account_async::store_account_credentials_with_test_timeout(
164+
&storage,
165+
"issuer",
166+
test_account_credentials(),
167+
std::time::Duration::from_millis(50),
168+
);
169+
let remove = crate::acme_account_async::remove_account_credentials_with_test_timeout(
170+
&storage,
171+
"issuer",
172+
std::time::Duration::from_millis(50),
173+
);
174+
let heartbeat_task = {
175+
let heartbeat = heartbeat.clone();
176+
async move {
177+
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
178+
heartbeat.store(true, std::sync::atomic::Ordering::Release);
179+
}
180+
};
181+
let (store_result, remove_result, ()) =
182+
futures::future::join3(store, remove, heartbeat_task).await;
183+
assert!(store_result.unwrap_err().to_string().contains("timed out"));
184+
assert!(remove_result.unwrap_err().to_string().contains("timed out"));
185+
});
186+
187+
assert!(heartbeat.load(std::sync::atomic::Ordering::Acquire));
188+
assert!(started.elapsed() < std::time::Duration::from_secs(1));
189+
drop(pending);
190+
}
191+
145192
#[cfg(feature = "acme-client")]
146193
#[test]
147194
fn account_bootstrap_promotion_is_durable_and_cleans_pending_key() {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
use super::*;
2+
3+
#[test]
4+
fn account_credentials_store_load_and_remove_round_trip() {
5+
let storage = fluxheim_common::test_support::unique_temp_path("acme-account-async-roundtrip");
6+
let runtime = tokio::runtime::Builder::new_current_thread()
7+
.enable_all()
8+
.build()
9+
.unwrap();
10+
11+
runtime.block_on(async {
12+
let stored = crate::store_account_credentials_async(
13+
&storage,
14+
"letsencrypt",
15+
test_account_credentials(),
16+
)
17+
.await
18+
.unwrap();
19+
assert!(stored.path.is_file());
20+
assert!(
21+
crate::load_account_credentials_async(&storage, "letsencrypt")
22+
.await
23+
.unwrap()
24+
.is_some()
25+
);
26+
assert!(
27+
crate::remove_account_credentials_async(&storage, "letsencrypt")
28+
.await
29+
.unwrap()
30+
);
31+
assert!(
32+
crate::load_account_credentials_async(&storage, "letsencrypt")
33+
.await
34+
.unwrap()
35+
.is_none()
36+
);
37+
});
38+
}

crates/fluxheim-acme/src/lib.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,16 @@ mod acme_tls_alpn;
261261
#[path = "acme_transaction.rs"]
262262
mod acme_transaction;
263263
#[cfg(feature = "acme-client")]
264+
pub use acme_account_async::{
265+
load_account_credentials_async, remove_account_credentials_async,
266+
store_account_credentials_async,
267+
};
268+
#[cfg(feature = "acme-client")]
264269
use acme_account_store::{
265270
AccountBootstrap, AccountDeactivationTransaction, AccountStoreAttempt, PendingAccountBootstrap,
266-
try_begin_account_bootstrap, try_begin_account_deactivation, try_load_account_credentials,
271+
ensure_safe_account_destination, ensure_safe_account_directory,
272+
store_account_credentials_locked, try_begin_account_bootstrap, try_begin_account_deactivation,
273+
try_load_account_credentials,
267274
};
268275
pub use acme_account_store::{
269276
account_credentials_path, load_account_credentials, remove_account_credentials,

0 commit comments

Comments
 (0)