Skip to content

Commit 0d486bc

Browse files
committed
Fail closed on pending ACME account state
1 parent d0eb4a5 commit 0d486bc

8 files changed

Lines changed: 141 additions & 50 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ behavior when the change improves security or project direction.
2727
account state fails closed, while revocation quarantines the active pair before
2828
the remote operation, preserves ambiguous outcomes for operator resolution,
2929
and requests live reload after success even when scheduled renewal is disabled.
30+
Ordinary synchronous and async credential store/removal APIs now reject both
31+
bootstrap and ambiguous deactivation journals while holding the lifecycle lock.
3032
- Journal revocation quarantine phases durably so pre-remote crashes restore the
3133
complete pair, ambiguous remote outcomes stay fail-closed, and confirmed
3234
quarantine survives crashes while permitting replacement issuance.

crates/fluxheim-acme/src/acme_account_async.rs

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,7 @@ fn try_store_account_credentials(
8888
else {
8989
return Ok(AccountStoreAttempt::Contended);
9090
};
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-
}
91+
reject_pending_account_mutation(directory)?;
10292
store_account_credentials_locked(&credentials_path, directory, credentials)
10393
.map(AccountStoreAttempt::Acquired)
10494
}
@@ -116,6 +106,7 @@ fn try_remove_account_credentials(
116106
else {
117107
return Ok(AccountStoreAttempt::Contended);
118108
};
109+
reject_pending_account_mutation(directory)?;
119110
ensure_safe_account_destination(&credentials_path.path)?;
120111
let removed = match fs::remove_file(&credentials_path.path) {
121112
Ok(()) => {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use std::fs;
2+
use std::io;
3+
use std::path::Path;
4+
5+
use super::AcmeAccountStoreError;
6+
7+
pub(crate) const ACCOUNT_BOOTSTRAP_PENDING_FILE: &str = ".credentials.bootstrap.pending";
8+
pub(crate) const ACCOUNT_DEACTIVATION_PENDING_FILE: &str = ".credentials.deactivation.pending";
9+
10+
pub(crate) fn reject_pending_account_mutation(
11+
directory: &Path,
12+
) -> Result<(), AcmeAccountStoreError> {
13+
reject_pending_account_bootstrap(directory)?;
14+
reject_pending_account_deactivation(directory)
15+
}
16+
17+
pub(crate) fn reject_pending_account_bootstrap(
18+
directory: &Path,
19+
) -> Result<(), AcmeAccountStoreError> {
20+
reject_pending_account_state(
21+
directory,
22+
ACCOUNT_BOOTSTRAP_PENDING_FILE,
23+
"account bootstrap is pending; bootstrap recovery must complete",
24+
)
25+
}
26+
27+
pub(crate) fn reject_pending_account_deactivation(
28+
directory: &Path,
29+
) -> Result<(), AcmeAccountStoreError> {
30+
reject_pending_account_state(
31+
directory,
32+
ACCOUNT_DEACTIVATION_PENDING_FILE,
33+
"account deactivation is in an ambiguous pending state; operator recovery is required",
34+
)
35+
}
36+
37+
fn reject_pending_account_state(
38+
directory: &Path,
39+
name: &str,
40+
message: &str,
41+
) -> Result<(), AcmeAccountStoreError> {
42+
let path = directory.join(name);
43+
match fs::symlink_metadata(&path) {
44+
Ok(_) => Err(AcmeAccountStoreError::UnsafePath {
45+
path,
46+
message: message.to_owned(),
47+
}),
48+
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
49+
Err(error) => Err(AcmeAccountStoreError::Io { path, error }),
50+
}
51+
}
52+
53+
#[cfg(all(test, unix))]
54+
mod tests {
55+
use super::*;
56+
57+
#[test]
58+
fn pending_mutation_guard_rejects_dangling_symlink() {
59+
let storage = fluxheim_common::test_support::unique_temp_path("acme-account-state-link");
60+
let credentials = crate::account_credentials_path(&storage, "issuer");
61+
let directory = credentials.path.parent().unwrap().to_path_buf();
62+
fs::create_dir_all(&directory).unwrap();
63+
std::os::unix::fs::symlink(
64+
directory.join("missing-target"),
65+
directory.join(ACCOUNT_BOOTSTRAP_PENDING_FILE),
66+
)
67+
.unwrap();
68+
69+
let error = reject_pending_account_mutation(&directory).unwrap_err();
70+
assert!(matches!(error, AcmeAccountStoreError::UnsafePath { .. }));
71+
let load_error = match crate::load_account_credentials(&storage, "issuer") {
72+
Ok(_) => panic!("expected dangling pending state to fail closed"),
73+
Err(error) => error,
74+
};
75+
assert!(matches!(
76+
load_error,
77+
AcmeAccountStoreError::UnsafePath { .. }
78+
));
79+
}
80+
}

crates/fluxheim-acme/src/acme_account_store.rs

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ pub(crate) use bootstrap::{
1919
use super::UNIX_O_NOFOLLOW;
2020
use super::{
2121
AcmeAccountCredentialsPath, AcmeAccountStoreError, AcmeMutationLock,
22-
MAX_ACCOUNT_CREDENTIALS_BYTES, managed_certificate_segment, unique_transaction_id,
22+
MAX_ACCOUNT_CREDENTIALS_BYTES, managed_certificate_segment, reject_pending_account_bootstrap,
23+
reject_pending_account_deactivation, reject_pending_account_mutation, unique_transaction_id,
2324
};
2425

2526
const ACME_ACCOUNT_DIR: &str = "accounts";
2627
const ACME_ACCOUNT_CREDENTIALS_FILE: &str = "credentials.json";
27-
const ACME_ACCOUNT_DEACTIVATION_FILE: &str = ".credentials.deactivation.pending";
2828

2929
#[cfg(feature = "acme-client")]
3030
pub(crate) enum AccountStoreAttempt<T> {
@@ -120,29 +120,9 @@ fn load_account_credentials_locked(
120120
directory: &Path,
121121
allow_bootstrap_recovery: bool,
122122
) -> Result<Option<instant_acme::AccountCredentials>, AcmeAccountStoreError> {
123-
let pending = directory.join(ACME_ACCOUNT_DEACTIVATION_FILE);
124-
if pending
125-
.try_exists()
126-
.map_err(|error| account_store_io_error(&pending, error))?
127-
{
128-
return Err(AcmeAccountStoreError::UnsafePath {
129-
path: pending,
130-
message: "account deactivation is in an ambiguous pending state; operator recovery is required"
131-
.to_owned(),
132-
});
133-
}
134-
let bootstrap_pending = directory.join(".credentials.bootstrap.pending");
135-
if !allow_bootstrap_recovery
136-
&& bootstrap_pending
137-
.try_exists()
138-
.map_err(|error| account_store_io_error(&bootstrap_pending, error))?
139-
{
140-
return Err(AcmeAccountStoreError::UnsafePath {
141-
path: bootstrap_pending,
142-
message:
143-
"account bootstrap is pending; recovery must complete before credentials are used"
144-
.to_owned(),
145-
});
123+
reject_pending_account_deactivation(directory)?;
124+
if !allow_bootstrap_recovery {
125+
reject_pending_account_bootstrap(directory)?;
146126
}
147127
let metadata = match fs::symlink_metadata(path) {
148128
Ok(metadata) => metadata,
@@ -243,7 +223,7 @@ fn begin_account_deactivation_locked(
243223
lock: AcmeMutationLock,
244224
) -> Result<AccountDeactivationTransaction, AcmeAccountStoreError> {
245225
ensure_safe_account_destination(&active)?;
246-
let pending = directory.join(ACME_ACCOUNT_DEACTIVATION_FILE);
226+
let pending = directory.join(super::acme_account_state::ACCOUNT_DEACTIVATION_PENDING_FILE);
247227
match fs::symlink_metadata(&pending) {
248228
Ok(_) => {
249229
return Err(AcmeAccountStoreError::UnsafePath {
@@ -305,17 +285,7 @@ pub fn store_account_credentials(
305285
ensure_safe_account_directory(directory)?;
306286
let _mutation_lock = AcmeMutationLock::acquire(directory)
307287
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
308-
let bootstrap_pending = directory.join(".credentials.bootstrap.pending");
309-
if bootstrap_pending
310-
.try_exists()
311-
.map_err(|error| account_store_io_error(&bootstrap_pending, error))?
312-
{
313-
return Err(AcmeAccountStoreError::UnsafePath {
314-
path: bootstrap_pending,
315-
message: "account bootstrap is pending; credentials must be promoted by the bootstrap transaction"
316-
.to_owned(),
317-
});
318-
}
288+
reject_pending_account_mutation(directory)?;
319289
store_account_credentials_locked(&credentials_path, directory, credentials)
320290
}
321291

@@ -379,6 +349,7 @@ pub fn remove_account_credentials(
379349
ensure_safe_account_directory(directory)?;
380350
let _mutation_lock = AcmeMutationLock::acquire(directory)
381351
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
352+
reject_pending_account_mutation(directory)?;
382353
ensure_safe_account_destination(&credentials_path.path)?;
383354
match fs::remove_file(&credentials_path.path) {
384355
Ok(()) => {

crates/fluxheim-acme/src/acme_tests_storage.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,20 @@ fn account_deactivation_quarantine_is_fail_closed_and_recoverable() {
8080

8181
let transaction = crate::begin_account_deactivation(&storage, "letsencrypt").unwrap();
8282
transaction.abandon();
83+
let active = account_credentials_path(&storage, "letsencrypt").path;
84+
assert!(!active.exists());
85+
let store_error =
86+
store_account_credentials(&storage, "letsencrypt", &test_account_credentials())
87+
.unwrap_err();
88+
assert!(store_error.to_string().contains("ambiguous pending state"));
89+
let remove_error = remove_account_credentials(&storage, "letsencrypt").unwrap_err();
90+
assert!(remove_error.to_string().contains("ambiguous pending state"));
91+
assert!(!active.exists());
8392
let pending = match load_account_credentials(&storage, "letsencrypt") {
8493
Ok(_) => panic!("expected pending deactivation to fail closed"),
8594
Err(error) => error,
8695
};
8796
assert!(pending.to_string().contains("ambiguous pending state"));
88-
let active = account_credentials_path(&storage, "letsencrypt").path;
8997
std::fs::rename(
9098
active
9199
.parent()

crates/fluxheim-acme/src/acme_tests_storage_async.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,34 @@ fn account_credentials_store_load_and_remove_round_trip() {
3636
);
3737
});
3838
}
39+
40+
#[test]
41+
fn account_mutations_reject_ambiguous_deactivation_state() {
42+
let storage = fluxheim_common::test_support::unique_temp_path("acme-account-async-pending");
43+
store_account_credentials(&storage, "letsencrypt", &test_account_credentials()).unwrap();
44+
crate::begin_account_deactivation(&storage, "letsencrypt")
45+
.unwrap()
46+
.abandon();
47+
let active = account_credentials_path(&storage, "letsencrypt").path;
48+
let runtime = tokio::runtime::Builder::new_current_thread()
49+
.enable_all()
50+
.build()
51+
.unwrap();
52+
53+
runtime.block_on(async {
54+
let store_error = crate::store_account_credentials_async(
55+
&storage,
56+
"letsencrypt",
57+
test_account_credentials(),
58+
)
59+
.await
60+
.unwrap_err();
61+
assert!(store_error.to_string().contains("ambiguous pending state"));
62+
let remove_error = crate::remove_account_credentials_async(&storage, "letsencrypt")
63+
.await
64+
.unwrap_err();
65+
assert!(remove_error.to_string().contains("ambiguous pending state"));
66+
});
67+
68+
assert!(!active.exists());
69+
}

crates/fluxheim-acme/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,8 @@ impl fmt::Debug for AcmeExternalAccountBindingSecrets {
221221
#[cfg(feature = "acme-client")]
222222
#[path = "acme_account_async.rs"]
223223
mod acme_account_async;
224+
#[path = "acme_account_state.rs"]
225+
mod acme_account_state;
224226
#[path = "acme_account_store.rs"]
225227
mod acme_account_store;
226228
#[cfg(feature = "acme-client")]
@@ -265,6 +267,10 @@ pub use acme_account_async::{
265267
load_account_credentials_async, remove_account_credentials_async,
266268
store_account_credentials_async,
267269
};
270+
use acme_account_state::{
271+
reject_pending_account_bootstrap, reject_pending_account_deactivation,
272+
reject_pending_account_mutation,
273+
};
268274
#[cfg(feature = "acme-client")]
269275
use acme_account_store::{
270276
AccountBootstrap, AccountDeactivationTransaction, AccountStoreAttempt, PendingAccountBootstrap,

docs/certificate-renewal.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ Crate consumers running inside Tokio should use
187187
`remove_account_credentials_async`. Their synchronous counterparts are
188188
intended for synchronous startup and maintenance code and may wait indefinitely
189189
on the advisory lifecycle lock; each is marked with a Rustdoc `# Blocking`
190-
contract.
190+
contract. Both API families reject ordinary credential stores and removals while
191+
a bootstrap or ambiguous deactivation journal exists, so recovery state cannot
192+
be hidden by publishing a second active credential file.
191193

192194
Pending account-key buffers use `sanitization::SecretVec`. The pending file is
193195
overwritten, synced, truncated, and unlinked after durable

0 commit comments

Comments
 (0)