Skip to content

Commit dcf8cc5

Browse files
committed
Harden ACME secrets and filesystem boundaries
1 parent db08b1b commit dcf8cc5

26 files changed

Lines changed: 503 additions & 294 deletions

CHANGELOG.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ behavior when the change improves security or project direction.
4444
retry, contention diagnostics, and a 10-second lock-wait deadline. Public
4545
bounded async credential load, store, and removal APIs prevent downstream
4646
Tokio callers from falling back to indefinitely blocking lifecycle methods.
47-
- Verify the vendored `instant-acme` source against published `0.8.5` hashes in
48-
CI and release gates after removing the single marked downstream API patch;
49-
separately pin the exact permitted patch body and use a private unpredictable
50-
validation workspace to prevent local symlink clobbering.
47+
- Verify unchanged vendored `instant-acme 0.8.5` files against published hashes
48+
and intentionally modified files against reviewed patched hashes plus an
49+
aggregate digest in CI and release gates, without creating
50+
attacker-addressable temporary paths.
5151
- Validate online ACME directories structurally and require exact advertised ToS
5252
agreement, with an explicit private-directory override only for omitted terms.
5353
- Parse every advertised ACME endpoint as a bounded HTTPS URI with a real
@@ -88,6 +88,12 @@ behavior when the change improves security or project direction.
8888
advisory leases; recover interrupted pair publication through a durable
8989
journal; preserve primary renewal errors; redact challenge secrets; and keep
9090
ACME account, EAB, and private-key material in `sanitization` containers.
91+
- Keep ACME account PKCS#8 bytes in `sanitization::SecretVec` across the patched
92+
client credential boundary, including drop-cleared Base64 serialization and
93+
deserialization buffers, bootstrap creation, and account recovery.
94+
- Create ACME account, certificate, and challenge directories component by
95+
component with descriptor-relative no-follow operations on Unix, and reuse
96+
the same traversal for account mutation and certificate read locks.
9197

9298
- Open snapshot files with no-follow semantics before validating the opened
9399
descriptor, closing the check-then-open race while preserving typed unsafe
@@ -99,6 +105,9 @@ behavior when the change improves security or project direction.
99105
descriptor and use descriptor-relative create, link, rename, cleanup,
100106
metadata, and durability operations, preventing pathname re-resolution and
101107
parent replacement during atomic writes.
108+
- Open the private snapshot mutation lock relative to the same no-symlink parent
109+
descriptor, with component-wise fallback traversal on platforms without
110+
Linux `openat2`.
102111

103112
## 1.7.8 - 2026-07-11
104113

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fluxheim-acme/src/acme_account_bootstrap.rs

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use super::*;
22
use crate::acme_account_state::ACCOUNT_BOOTSTRAP_PENDING_FILE;
3-
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
3+
use rustls::pki_types::PrivatePkcs8KeyDer;
44
use sanitization::SecretVec;
55
use sanitization::ct::ConstantTimeEq as _;
66
use sha2::{Digest as _, Sha256};
@@ -92,9 +92,11 @@ fn begin_account_bootstrap_locked(
9292
if let Some(credentials) = credentials {
9393
if let Some(pending) = pending {
9494
let matches = pending.with_secret(|pending| {
95-
pending
96-
.ct_eq(credentials.private_key().secret_pkcs8_der())
97-
.declassify("ACME bootstrap key recovery result is public")
95+
credentials.with_private_key(|key| {
96+
pending
97+
.ct_eq(key)
98+
.declassify("ACME bootstrap key recovery result is public")
99+
})
98100
});
99101
if !matches {
100102
return Err(AcmeAccountStoreError::UnsafePath {
@@ -111,13 +113,13 @@ fn begin_account_bootstrap_locked(
111113
let (key_der, initial_key, recovered) = match pending {
112114
Some(key_der) => (key_der, None, true),
113115
None => {
114-
let (key, generated) = instant_acme::Key::generate_pkcs8().map_err(|error| {
116+
let (key, generated) = instant_acme::Key::generate_secret_pkcs8().map_err(|error| {
115117
AcmeAccountStoreError::UnsafePath {
116118
path: pending_path.clone(),
117119
message: format!("failed to generate pending account key: {error}"),
118120
}
119121
})?;
120-
let key_der = SecretVec::from_slice(generated.secret_pkcs8_der());
122+
let key_der = generated;
121123
write_pending_key(directory, &pending_path, issuer_directory, &key_der)?;
122124
(key_der, Some(key), false)
123125
}
@@ -141,37 +143,39 @@ impl PendingAccountBootstrap {
141143

142144
pub(crate) fn key_pair(
143145
&mut self,
144-
) -> Result<(instant_acme::Key, PrivatePkcs8KeyDer<'static>), AcmeAccountStoreError> {
146+
) -> Result<(instant_acme::Key, SecretVec), AcmeAccountStoreError> {
145147
self.key_der.with_secret(|key_der| {
146148
let key = if let Some(key) = self.initial_key.take() {
147149
key
148150
} else {
149-
instant_acme::Key::from_pkcs8_der(PrivatePkcs8KeyDer::from(key_der.to_vec()))
150-
.map_err(|_| AcmeAccountStoreError::UnsafePath {
151+
instant_acme::Key::from_pkcs8_der(PrivatePkcs8KeyDer::from(key_der)).map_err(
152+
|_| AcmeAccountStoreError::UnsafePath {
151153
path: self.pending_path.clone(),
152154
message: "pending account key is not valid PKCS#8 P-256 material"
153155
.to_owned(),
154-
})?
156+
},
157+
)?
155158
};
156-
Ok((key, PrivatePkcs8KeyDer::from(key_der.to_vec())))
159+
Ok((key, SecretVec::from_slice(key_der)))
157160
})
158161
}
159162

160163
pub(crate) fn existing_key_pair(
161164
&mut self,
162-
) -> Result<(instant_acme::Key, PrivateKeyDer<'static>), AcmeAccountStoreError> {
163-
let (key, key_der) = self.key_pair()?;
164-
Ok((key, PrivateKeyDer::Pkcs8(key_der)))
165+
) -> Result<(instant_acme::Key, SecretVec), AcmeAccountStoreError> {
166+
self.key_pair()
165167
}
166168

167169
pub(crate) fn promote(
168170
self,
169171
credentials: &instant_acme::AccountCredentials,
170172
) -> Result<(), AcmeAccountStoreError> {
171173
let matches = self.key_der.with_secret(|pending| {
172-
pending
173-
.ct_eq(credentials.private_key().secret_pkcs8_der())
174-
.declassify("ACME bootstrap credential key match result is public")
174+
credentials.with_private_key(|key| {
175+
pending
176+
.ct_eq(key)
177+
.declassify("ACME bootstrap credential key match result is public")
178+
})
175179
});
176180
if !matches {
177181
return Err(AcmeAccountStoreError::UnsafePath {

crates/fluxheim-acme/src/acme_account_store.rs

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use std::fs;
22
use std::io;
33
use std::io::{Read, Write};
4-
use std::path::{Path, PathBuf};
4+
use std::path::Path;
5+
#[cfg(any(feature = "acme-client", not(unix)))]
6+
use std::path::PathBuf;
57

68
use sanitization::SecretVec;
79

@@ -362,19 +364,29 @@ pub fn remove_account_credentials(
362364
}
363365

364366
pub(crate) fn ensure_safe_account_directory(directory: &Path) -> Result<(), AcmeAccountStoreError> {
365-
reject_existing_symlink_in_account_path(directory)?;
366-
fs::create_dir_all(directory).map_err(|error| account_store_io_error(directory, error))?;
367-
reject_existing_symlink_in_account_path(directory)?;
368-
let metadata = fs::symlink_metadata(directory)
369-
.map_err(|error| account_store_io_error(directory, error))?;
370-
if metadata.file_type().is_symlink() || !metadata.is_dir() {
371-
return Err(AcmeAccountStoreError::UnsafePath {
372-
path: directory.to_path_buf(),
373-
message: "path is not a real directory".to_owned(),
374-
});
367+
#[cfg(unix)]
368+
{
369+
crate::acme_directory::create_private_directory_all(directory)
370+
.map_err(|error| account_store_io_error(directory, error))?;
371+
Ok(())
375372
}
376373

377-
Ok(())
374+
#[cfg(not(unix))]
375+
{
376+
reject_existing_symlink_in_account_path(directory)?;
377+
fs::create_dir_all(directory).map_err(|error| account_store_io_error(directory, error))?;
378+
reject_existing_symlink_in_account_path(directory)?;
379+
let metadata = fs::symlink_metadata(directory)
380+
.map_err(|error| account_store_io_error(directory, error))?;
381+
if metadata.file_type().is_symlink() || !metadata.is_dir() {
382+
return Err(AcmeAccountStoreError::UnsafePath {
383+
path: directory.to_path_buf(),
384+
message: "path is not a real directory".to_owned(),
385+
});
386+
}
387+
388+
Ok(())
389+
}
378390
}
379391

380392
pub(crate) fn ensure_safe_account_destination(path: &Path) -> Result<(), AcmeAccountStoreError> {
@@ -391,6 +403,7 @@ pub(crate) fn ensure_safe_account_destination(path: &Path) -> Result<(), AcmeAcc
391403
}
392404
}
393405

406+
#[cfg(not(unix))]
394407
fn reject_existing_symlink_in_account_path(path: &Path) -> Result<(), AcmeAccountStoreError> {
395408
let mut current = PathBuf::new();
396409
for component in path.components() {

crates/fluxheim-acme/src/acme_certificate_install.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ mod revocation_fs;
1414
use backup::{backup_existing_file, cleanup_backup};
1515
#[cfg(not(unix))]
1616
use fs_ops::CertificateDirectoryFd;
17-
pub(crate) use fs_ops::{
18-
ManagedCertificateOwner, managed_certificate_owner, reject_existing_symlink_in_path,
19-
};
17+
#[cfg(not(unix))]
18+
pub(crate) use fs_ops::reject_existing_symlink_in_path;
19+
pub(crate) use fs_ops::{ManagedCertificateOwner, managed_certificate_owner};
2020
use fs_ops::{
2121
certificate_directory, ensure_safe_destination, ensure_safe_directory,
2222
open_safe_certificate_directory, rename_certificate_file, write_new_file,

crates/fluxheim-acme/src/acme_certificate_install_fs.rs

Lines changed: 38 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -95,46 +95,43 @@ pub(super) fn ensure_safe_directory(
9595
directory: &Path,
9696
owner: ManagedCertificateOwner,
9797
) -> Result<(), AcmeCertificateInstallError> {
98-
reject_existing_symlink_in_path(directory)?;
99-
fs::create_dir_all(directory).map_err(|error| AcmeCertificateInstallError::Io {
100-
path: directory.to_path_buf(),
101-
error,
102-
})?;
103-
apply_owner_to_path(directory, owner)?;
104-
reject_existing_symlink_in_path(directory)?;
105-
let metadata =
106-
fs::symlink_metadata(directory).map_err(|error| AcmeCertificateInstallError::Io {
98+
#[cfg(unix)]
99+
{
100+
let directory_fd =
101+
crate::acme_directory::create_private_directory_all(directory).map_err(|error| {
102+
AcmeCertificateInstallError::Io {
103+
path: directory.to_path_buf(),
104+
error,
105+
}
106+
})?;
107+
let directory_file = fs::File::from(directory_fd);
108+
apply_owner_to_file(&directory_file, directory, owner)?;
109+
Ok(())
110+
}
111+
112+
#[cfg(not(unix))]
113+
{
114+
reject_existing_symlink_in_path(directory)?;
115+
fs::create_dir_all(directory).map_err(|error| AcmeCertificateInstallError::Io {
107116
path: directory.to_path_buf(),
108117
error,
109118
})?;
110-
if metadata.file_type().is_symlink() || !metadata.is_dir() {
111-
return Err(AcmeCertificateInstallError::UnsafePath {
112-
path: directory.to_path_buf(),
113-
message: "path is not a real directory".to_owned(),
114-
});
115-
}
116-
117-
Ok(())
118-
}
119-
120-
#[cfg(unix)]
121-
fn apply_owner_to_path(
122-
path: &Path,
123-
owner: ManagedCertificateOwner,
124-
) -> Result<(), AcmeCertificateInstallError> {
125-
let Some(owner) = owner else {
126-
return Ok(());
127-
};
119+
apply_owner_to_path(directory, owner)?;
120+
reject_existing_symlink_in_path(directory)?;
121+
let metadata =
122+
fs::symlink_metadata(directory).map_err(|error| AcmeCertificateInstallError::Io {
123+
path: directory.to_path_buf(),
124+
error,
125+
})?;
126+
if metadata.file_type().is_symlink() || !metadata.is_dir() {
127+
return Err(AcmeCertificateInstallError::UnsafePath {
128+
path: directory.to_path_buf(),
129+
message: "path is not a real directory".to_owned(),
130+
});
131+
}
128132

129-
rustix::fs::chown(
130-
path,
131-
Some(rustix::fs::Uid::from_raw(owner.uid)),
132-
Some(rustix::fs::Gid::from_raw(owner.gid)),
133-
)
134-
.map_err(|error| AcmeCertificateInstallError::Io {
135-
path: path.to_path_buf(),
136-
error: error.into(),
137-
})
133+
Ok(())
134+
}
138135
}
139136

140137
#[cfg(not(unix))]
@@ -192,6 +189,7 @@ pub(super) fn ensure_safe_destination(path: &Path) -> Result<(), AcmeCertificate
192189
}
193190
}
194191

192+
#[cfg(not(unix))]
195193
pub(crate) fn reject_existing_symlink_in_path(
196194
path: &Path,
197195
) -> Result<(), AcmeCertificateInstallError> {
@@ -311,40 +309,11 @@ fn certificate_file_raw_mode(
311309
pub(super) fn open_safe_certificate_directory(
312310
directory: &Path,
313311
) -> Result<rustix::fd::OwnedFd, AcmeCertificateInstallError> {
314-
#[cfg(target_os = "linux")]
315-
{
316-
match rustix::fs::openat2(
317-
rustix::fs::CWD,
318-
directory,
319-
rustix::fs::OFlags::RDONLY
320-
| rustix::fs::OFlags::DIRECTORY
321-
| rustix::fs::OFlags::CLOEXEC,
322-
rustix::fs::Mode::empty(),
323-
rustix::fs::ResolveFlags::NO_SYMLINKS | rustix::fs::ResolveFlags::NO_MAGICLINKS,
324-
) {
325-
Ok(fd) => return Ok(fd),
326-
Err(rustix::io::Errno::NOSYS | rustix::io::Errno::INVAL) => {}
327-
Err(error) => {
328-
return Err(AcmeCertificateInstallError::Io {
329-
path: directory.to_path_buf(),
330-
error: error.into(),
331-
});
332-
}
312+
crate::acme_directory::open_directory_no_symlinks(directory).map_err(|error| {
313+
AcmeCertificateInstallError::Io {
314+
path: directory.to_path_buf(),
315+
error,
333316
}
334-
}
335-
336-
rustix::fs::openat(
337-
rustix::fs::CWD,
338-
directory,
339-
rustix::fs::OFlags::RDONLY
340-
| rustix::fs::OFlags::DIRECTORY
341-
| rustix::fs::OFlags::NOFOLLOW
342-
| rustix::fs::OFlags::CLOEXEC,
343-
rustix::fs::Mode::empty(),
344-
)
345-
.map_err(|error| AcmeCertificateInstallError::Io {
346-
path: directory.to_path_buf(),
347-
error: error.into(),
348317
})
349318
}
350319

crates/fluxheim-acme/src/acme_challenges.rs

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -157,19 +157,28 @@ impl AcmeHttp01ChallengeStore {
157157
}
158158

159159
fn ensure_root_directory(&self) -> io::Result<()> {
160-
reject_existing_symlink_in_path(&self.root)
161-
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
162-
fs::create_dir_all(&self.root)?;
163-
reject_existing_symlink_in_path(&self.root)
164-
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
165-
let metadata = fs::symlink_metadata(&self.root)?;
166-
if metadata.file_type().is_symlink() || !metadata.is_dir() {
167-
return Err(io::Error::new(
168-
io::ErrorKind::InvalidInput,
169-
"ACME HTTP-01 challenge root is not a real directory",
170-
));
160+
#[cfg(unix)]
161+
{
162+
crate::acme_directory::create_private_directory_all(&self.root)?;
163+
Ok(())
164+
}
165+
166+
#[cfg(not(unix))]
167+
{
168+
reject_existing_symlink_in_path(&self.root)
169+
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
170+
fs::create_dir_all(&self.root)?;
171+
reject_existing_symlink_in_path(&self.root)
172+
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
173+
let metadata = fs::symlink_metadata(&self.root)?;
174+
if metadata.file_type().is_symlink() || !metadata.is_dir() {
175+
return Err(io::Error::new(
176+
io::ErrorKind::InvalidInput,
177+
"ACME HTTP-01 challenge root is not a real directory",
178+
));
179+
}
180+
Ok(())
171181
}
172-
Ok(())
173182
}
174183

175184
fn token_path(&self, token: &str) -> io::Result<PathBuf> {

0 commit comments

Comments
 (0)