Skip to content

Commit 27e6580

Browse files
committed
Harden ACME lifecycle and storage
1 parent a97a217 commit 27e6580

44 files changed

Lines changed: 2505 additions & 302 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ behavior when the change improves security or project direction.
1111

1212
### Added
1313

14+
- Add bounded ACME HTTP transport, RFC 9773 ARI scheduling, explicit
15+
terms-of-service records, per-issuer CA bundles, `fluxheim-acme doctor`, and
16+
confirmation-gated account rollover/deactivation and certificate revocation.
17+
1418
- Add the checked-in F5 iRules-style route access policy and configuration
1519
example, with real listener coverage for normal origin traffic, pre-origin
1620
denial, and fail-closed plugin traps.
@@ -39,6 +43,12 @@ behavior when the change improves security or project direction.
3943

4044
### Security
4145

46+
- Validate issued certificate chains, key matches, validity, server-auth usage,
47+
and exact DNS SANs before publication; serialize writers and TLS readers with
48+
advisory leases; recover interrupted pair publication through a durable
49+
journal; preserve primary renewal errors; redact challenge secrets; and keep
50+
ACME account, EAB, and private-key material in `sanitization` containers.
51+
4252
- Open snapshot files with no-follow semantics before validating the opened
4353
descriptor, closing the check-then-open race while preserving typed unsafe
4454
path rejection.

Cargo.lock

Lines changed: 23 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ acme-client = [
121121
"acme",
122122
"fluxheim-acme/acme-client",
123123
"dep:rpassword",
124+
"dep:sanitization",
124125
"dep:tokio",
125126
]
126127
metrics = [

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Fluxheim is licensed under the European Union Public Licence 1.2.
102102
| mTLS/client certificates || Listener client-auth, fingerprint ACLs, and safe upstream identity forwarding templates. |
103103
| TLS backends || rustls default/recommended, plus OpenSSL for operators who need OpenSSL integration or OpenSSL FIPS provider deployments. |
104104
| FIPS/ISO-capable builds || OpenSSL FIPS provider path and rustls/AWS-LC FIPS-capable candidate path. |
105-
| ACME || Managed HTTP-01 and rustls TLS-ALPN-01 issuance/renewal, plus external HTTP-01 forwarding helper. |
105+
| ACME || Managed HTTP-01 and rustls TLS-ALPN-01 issuance/renewal, bounded and recoverable certificate publication, ARI scheduling, lifecycle/preflight commands, plus external HTTP-01 forwarding. |
106106
| PROXY protocol || v1/v2 receive and upstream send. |
107107
| HTTP/2 origins || Upstream HTTP version controls and bounded HTTP/2 settings. |
108108
| gRPC pass-through || Route-scoped HTTP/2 gRPC policy; no transcoding. |

crates/fluxheim-acme/Cargo.toml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,41 @@ description = "Internal Fluxheim ACME account, challenge, renewal, and certifica
1111
default = []
1212
acme-client = [
1313
"dep:base64-ng",
14-
"dep:rcgen",
14+
"dep:bytes",
15+
"dep:http",
16+
"dep:http-body-util",
17+
"dep:hyper",
18+
"dep:hyper-rustls",
19+
"dep:hyper-util",
20+
"dep:rustls",
21+
"dep:rustls-pemfile",
22+
"dep:tokio",
1523
"instant-acme/hyper-rustls",
1624
]
1725

1826
[dependencies]
1927
base64-ng = { version = "1.3.7", optional = true }
28+
bytes = { version = "1.12.1", optional = true }
2029
fluxheim-config = { path = "../fluxheim-config", features = ["acme"] }
21-
instant-acme = { version = "0.8.5", default-features = false, features = ["ring"] }
30+
getrandom = "0.4.3"
31+
http = { version = "1.4.2", optional = true }
32+
http-body-util = { version = "0.1.3", optional = true }
33+
hyper = { version = "1.10.1", default-features = false, features = ["client", "http1", "http2"], optional = true }
34+
hyper-rustls = { version = "0.27.9", default-features = false, features = ["http1", "http2", "native-tokio", "ring", "tls12"], optional = true }
35+
hyper-util = { version = "0.1.20", default-features = false, features = ["client-legacy", "http1", "http2", "tokio"], optional = true }
36+
instant-acme = { version = "0.8.5", default-features = false, features = ["ring", "time", "x509-parser"] }
2237
log = "0.4.33"
23-
rcgen = { version = "0.14.8", default-features = false, features = ["pem", "ring"], optional = true }
38+
rcgen = { version = "0.14.8", default-features = false, features = ["pem", "ring"] }
2439
rustix = { version = "1.1.4", features = ["fs", "process"] }
40+
rustls = { version = "0.23.41", default-features = false, features = ["ring", "std", "tls12"], optional = true }
41+
rustls-pemfile = { version = "2.2.0", optional = true }
2542
sanitization = { version = "1.2.4", features = ["alloc"] }
43+
serde = { version = "1.0.228", features = ["derive"] }
2644
serde_json = "1.0.150"
2745
sha2 = "0.11.0"
2846
toml = "1.1.2"
47+
tokio = { version = "1.52.3", default-features = false, features = ["rt", "time"], optional = true }
2948
x509-parser = { version = "0.18.1", default-features = false }
30-
zeroize = { version = "1.9.0", features = ["derive"] }
3149

3250
[dev-dependencies]
3351
fluxheim-common = { path = "../fluxheim-common", features = ["test-support"] }

crates/fluxheim-acme/src/acme_account_store.rs

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use sanitization::SecretVec;
88
#[cfg(target_os = "linux")]
99
use super::UNIX_O_NOFOLLOW;
1010
use super::{
11-
AcmeAccountCredentialsPath, AcmeAccountStoreError, MAX_ACCOUNT_CREDENTIALS_BYTES,
12-
managed_certificate_segment,
11+
AcmeAccountCredentialsPath, AcmeAccountStoreError, AcmeMutationLock,
12+
MAX_ACCOUNT_CREDENTIALS_BYTES, managed_certificate_segment, unique_transaction_id,
1313
};
1414

1515
const ACME_ACCOUNT_DIR: &str = "accounts";
@@ -47,20 +47,32 @@ pub fn load_account_credentials(
4747
});
4848
}
4949

50-
let file = open_regular_account_credentials_file(&path)
50+
let mut file = open_regular_account_credentials_file(&path)
5151
.map_err(|error| account_store_io_error(&path, error))?;
52-
let mut contents = Vec::new();
53-
file.take(MAX_ACCOUNT_CREDENTIALS_BYTES.saturating_add(1))
54-
.read_to_end(&mut contents)
52+
let admitted =
53+
usize::try_from(metadata.len()).map_err(|_| AcmeAccountStoreError::Oversized {
54+
path: path.clone(),
55+
max_bytes: MAX_ACCOUNT_CREDENTIALS_BYTES,
56+
})?;
57+
let mut contents = SecretVec::from_fn(admitted, |_| 0);
58+
contents
59+
.with_secret_mut(|contents| file.read_exact(contents))
5560
.map_err(|error| account_store_io_error(&path, error))?;
56-
if contents.len() as u64 > MAX_ACCOUNT_CREDENTIALS_BYTES {
61+
let mut growth_probe = [0_u8; 1];
62+
let grew = file
63+
.read(&mut growth_probe)
64+
.map_err(|error| account_store_io_error(&path, error))?
65+
!= 0;
66+
sanitization::SecureSanitize::secure_sanitize(&mut growth_probe);
67+
if grew {
5768
return Err(AcmeAccountStoreError::Oversized {
5869
path,
5970
max_bytes: MAX_ACCOUNT_CREDENTIALS_BYTES,
6071
});
6172
}
6273

63-
serde_json::from_slice(&contents)
74+
contents
75+
.with_secret(|contents| serde_json::from_slice(contents))
6476
.map(Some)
6577
.map_err(|error| AcmeAccountStoreError::Deserialize {
6678
path,
@@ -83,6 +95,8 @@ pub fn store_account_credentials(
8395
message: "credentials path has no parent directory".to_owned(),
8496
})?;
8597
ensure_safe_account_directory(directory)?;
98+
let _mutation_lock = AcmeMutationLock::acquire(directory)
99+
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
86100
ensure_safe_account_destination(&credentials_path.path)?;
87101

88102
let contents = SecretVec::from_vec(serde_json::to_vec(credentials).map_err(|error| {
@@ -97,7 +111,9 @@ pub fn store_account_credentials(
97111
});
98112
}
99113

100-
let tmp_path = directory.join(".credentials.json.tmp");
114+
let transaction =
115+
unique_transaction_id().map_err(|error| account_store_io_error(directory, error))?;
116+
let tmp_path = directory.join(format!(".credentials.{transaction}.tmp"));
101117
let result = (|| {
102118
contents.with_secret(|contents| write_account_credentials_file(&tmp_path, contents))?;
103119
fs::rename(&tmp_path, &credentials_path.path)
@@ -114,6 +130,33 @@ pub fn store_account_credentials(
114130
Ok(credentials_path)
115131
}
116132

133+
pub fn remove_account_credentials(
134+
storage: &Path,
135+
issuer_name: &str,
136+
) -> Result<bool, AcmeAccountStoreError> {
137+
let credentials_path = account_credentials_path(storage, issuer_name);
138+
let directory =
139+
credentials_path
140+
.path
141+
.parent()
142+
.ok_or_else(|| AcmeAccountStoreError::UnsafePath {
143+
path: credentials_path.path.clone(),
144+
message: "credentials path has no parent directory".to_owned(),
145+
})?;
146+
ensure_safe_account_directory(directory)?;
147+
let _mutation_lock = AcmeMutationLock::acquire(directory)
148+
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
149+
ensure_safe_account_destination(&credentials_path.path)?;
150+
match fs::remove_file(&credentials_path.path) {
151+
Ok(()) => {
152+
sync_account_directory(directory)?;
153+
Ok(true)
154+
}
155+
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
156+
Err(error) => Err(account_store_io_error(&credentials_path.path, error)),
157+
}
158+
}
159+
117160
fn ensure_safe_account_directory(directory: &Path) -> Result<(), AcmeAccountStoreError> {
118161
reject_existing_symlink_in_account_path(directory)?;
119162
fs::create_dir_all(directory).map_err(|error| account_store_io_error(directory, error))?;

0 commit comments

Comments
 (0)