Skip to content

Commit 1287bc2

Browse files
committed
Harden ACME lifecycle transactions
1 parent 27e6580 commit 1287bc2

24 files changed

Lines changed: 1132 additions & 234 deletions

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,21 @@ behavior when the change improves security or project direction.
1313

1414
- Add bounded ACME HTTP transport, RFC 9773 ARI scheduling, explicit
1515
terms-of-service records, per-issuer CA bundles, `fluxheim-acme doctor`, and
16-
confirmation-gated account rollover/deactivation and certificate revocation.
16+
confirmation-gated account deactivation and certificate revocation. Account
17+
rollover now fails before remote mutation until the client supports a
18+
caller-generated, pre-journaled replacement key.
19+
- Serialize TLS-ALPN challenge install and cleanup under the ACME mutation lock,
20+
with race coverage proving cleanup cannot leave a partial certificate pair.
21+
- Bound ARI planning by lookup concurrency, per-target timeout, and total budget;
22+
execute due work progressively and cache issuer guidance through Retry-After.
23+
- Make account deactivation and certificate revocation transactional. Pending
24+
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.
27+
- Validate online ACME directories structurally and require exact advertised ToS
28+
agreement, with an explicit private-directory override only for omitted terms.
29+
- Remove the unmaintained direct `rustls-pemfile` dependency in favor of the
30+
maintained `rustls-pki-types` PEM parser already provided through rustls.
1731

1832
- Add the checked-in F5 iRules-style route access policy and configuration
1933
example, with real listener coverage for normal origin traffic, pre-origin

Cargo.lock

Lines changed: 1 addition & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fluxheim-acme/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ acme-client = [
1414
"dep:bytes",
1515
"dep:http",
1616
"dep:http-body-util",
17+
"dep:futures",
1718
"dep:hyper",
1819
"dep:hyper-rustls",
1920
"dep:hyper-util",
2021
"dep:rustls",
21-
"dep:rustls-pemfile",
2222
"dep:tokio",
2323
"instant-acme/hyper-rustls",
2424
]
@@ -28,6 +28,7 @@ base64-ng = { version = "1.3.7", optional = true }
2828
bytes = { version = "1.12.1", optional = true }
2929
fluxheim-config = { path = "../fluxheim-config", features = ["acme"] }
3030
getrandom = "0.4.3"
31+
futures = { version = "0.3.32", optional = true }
3132
http = { version = "1.4.2", optional = true }
3233
http-body-util = { version = "0.1.3", optional = true }
3334
hyper = { version = "1.10.1", default-features = false, features = ["client", "http1", "http2"], optional = true }
@@ -38,7 +39,6 @@ log = "0.4.33"
3839
rcgen = { version = "0.14.8", default-features = false, features = ["pem", "ring"] }
3940
rustix = { version = "1.1.4", features = ["fs", "process"] }
4041
rustls = { version = "0.23.41", default-features = false, features = ["ring", "std", "tls12"], optional = true }
41-
rustls-pemfile = { version = "2.2.0", optional = true }
4242
sanitization = { version = "1.2.4", features = ["alloc"] }
4343
serde = { version = "1.0.228", features = ["derive"] }
4444
serde_json = "1.0.150"

crates/fluxheim-acme/src/acme_account_store.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ use super::{
1414

1515
const ACME_ACCOUNT_DIR: &str = "accounts";
1616
const ACME_ACCOUNT_CREDENTIALS_FILE: &str = "credentials.json";
17+
const ACME_ACCOUNT_DEACTIVATION_FILE: &str = ".credentials.deactivation.pending";
18+
19+
#[cfg(feature = "acme-client")]
20+
pub(crate) struct AccountDeactivationTransaction {
21+
directory: PathBuf,
22+
active: PathBuf,
23+
pending: PathBuf,
24+
_lock: AcmeMutationLock,
25+
}
1726

1827
pub fn account_credentials_path(storage: &Path, issuer_name: &str) -> AcmeAccountCredentialsPath {
1928
AcmeAccountCredentialsPath {
@@ -29,6 +38,36 @@ pub fn load_account_credentials(
2938
issuer_name: &str,
3039
) -> Result<Option<instant_acme::AccountCredentials>, AcmeAccountStoreError> {
3140
let path = account_credentials_path(storage, issuer_name).path;
41+
let directory = path
42+
.parent()
43+
.ok_or_else(|| AcmeAccountStoreError::UnsafePath {
44+
path: path.clone(),
45+
message: "credentials path has no parent directory".to_owned(),
46+
})?;
47+
match fs::symlink_metadata(directory) {
48+
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
49+
return Err(AcmeAccountStoreError::UnsafePath {
50+
path: directory.to_path_buf(),
51+
message: "account directory is not a real directory".to_owned(),
52+
});
53+
}
54+
Ok(_) => {}
55+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
56+
Err(error) => return Err(account_store_io_error(directory, error)),
57+
}
58+
let _lock = AcmeMutationLock::acquire(directory)
59+
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
60+
let pending = directory.join(ACME_ACCOUNT_DEACTIVATION_FILE);
61+
if pending
62+
.try_exists()
63+
.map_err(|error| account_store_io_error(&pending, error))?
64+
{
65+
return Err(AcmeAccountStoreError::UnsafePath {
66+
path: pending,
67+
message: "account deactivation is in an ambiguous pending state; operator recovery is required"
68+
.to_owned(),
69+
});
70+
}
3271
let metadata = match fs::symlink_metadata(&path) {
3372
Ok(metadata) => metadata,
3473
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
@@ -80,6 +119,61 @@ pub fn load_account_credentials(
80119
})
81120
}
82121

122+
#[cfg(feature = "acme-client")]
123+
pub(crate) fn begin_account_deactivation(
124+
storage: &Path,
125+
issuer_name: &str,
126+
) -> Result<AccountDeactivationTransaction, AcmeAccountStoreError> {
127+
let active = account_credentials_path(storage, issuer_name).path;
128+
let directory = active
129+
.parent()
130+
.ok_or_else(|| AcmeAccountStoreError::UnsafePath {
131+
path: active.clone(),
132+
message: "credentials path has no parent directory".to_owned(),
133+
})?;
134+
ensure_safe_account_directory(directory)?;
135+
let lock = AcmeMutationLock::acquire(directory)
136+
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
137+
ensure_safe_account_destination(&active)?;
138+
let pending = directory.join(ACME_ACCOUNT_DEACTIVATION_FILE);
139+
match fs::symlink_metadata(&pending) {
140+
Ok(_) => {
141+
return Err(AcmeAccountStoreError::UnsafePath {
142+
path: pending,
143+
message: "account deactivation transaction already exists".to_owned(),
144+
});
145+
}
146+
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
147+
Err(error) => return Err(account_store_io_error(&pending, error)),
148+
}
149+
fs::rename(&active, &pending).map_err(|error| account_store_io_error(&active, error))?;
150+
sync_account_directory(directory)?;
151+
Ok(AccountDeactivationTransaction {
152+
directory: directory.to_path_buf(),
153+
active,
154+
pending,
155+
_lock: lock,
156+
})
157+
}
158+
159+
#[cfg(feature = "acme-client")]
160+
impl AccountDeactivationTransaction {
161+
#[cfg(test)]
162+
pub(crate) fn abandon(self) {}
163+
164+
pub(crate) fn rollback(self) -> Result<(), AcmeAccountStoreError> {
165+
fs::rename(&self.pending, &self.active)
166+
.map_err(|error| account_store_io_error(&self.active, error))?;
167+
sync_account_directory(&self.directory)
168+
}
169+
170+
pub(crate) fn complete(self) -> Result<(), AcmeAccountStoreError> {
171+
fs::remove_file(&self.pending)
172+
.map_err(|error| account_store_io_error(&self.pending, error))?;
173+
sync_account_directory(&self.directory)
174+
}
175+
}
176+
83177
pub fn store_account_credentials(
84178
storage: &Path,
85179
issuer_name: &str,

0 commit comments

Comments
 (0)