Skip to content

Commit a4bbb79

Browse files
committed
Make ACME account bootstrap durable
1 parent 08d4226 commit a4bbb79

22 files changed

Lines changed: 4550 additions & 47 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ behavior when the change improves security or project direction.
3333
- Bind revocation to the exact certificate moved into quarantine while holding
3434
the certificate mutation lock, and make the advisory ARI cache recover from
3535
poisoned locks and timestamp overflow without aborting the process.
36+
- Persist a directory-bound ACME account key before remote account activation,
37+
serialize bootstrap per issuer, and recover ambiguous creation with the same
38+
key before atomically promoting credentials. A pinned `instant-acme 0.8.5`
39+
API patch preserves configured contacts and EAB with caller-provided keys.
3640
- Validate online ACME directories structurally and require exact advertised ToS
3741
agreement, with an explicit private-directory override only for omitted terms.
3842
- Parse every advertised ACME endpoint as a bounded HTTPS URI with a real

Cargo.lock

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

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,8 @@ zstd = { version = "0.13.3", default-features = false }
308308
[profile.release]
309309
panic = "abort"
310310
overflow-checks = true
311+
312+
[patch.crates-io]
313+
# Narrow downstream API patch: lets Fluxheim persist an ACME account key before
314+
# remote activation while retaining configured contacts and EAB parameters.
315+
instant-acme = { path = "vendor/instant-acme" }
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
use super::*;
2+
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
3+
use sanitization::SecretVec;
4+
use sanitization::ct::ConstantTimeEq as _;
5+
use sha2::{Digest as _, Sha256};
6+
7+
const ACCOUNT_BOOTSTRAP_PENDING_FILE: &str = ".credentials.bootstrap.pending";
8+
const ACCOUNT_BOOTSTRAP_MAGIC: &[u8] = b"FLUXHEIM-ACME-ACCOUNT-KEY-V1\0";
9+
const ACCOUNT_BOOTSTRAP_ISSUER_HASH_BYTES: usize = 32;
10+
const MAX_ACCOUNT_BOOTSTRAP_BYTES: u64 = 4096;
11+
12+
pub(crate) enum AccountBootstrap {
13+
Existing(instant_acme::AccountCredentials),
14+
Pending(PendingAccountBootstrap),
15+
}
16+
17+
pub(crate) struct PendingAccountBootstrap {
18+
directory: PathBuf,
19+
credentials_path: AcmeAccountCredentialsPath,
20+
pending_path: PathBuf,
21+
key_der: SecretVec,
22+
initial_key: Option<instant_acme::Key>,
23+
recovered: bool,
24+
_lock: AcmeMutationLock,
25+
}
26+
27+
pub(crate) fn begin_account_bootstrap(
28+
storage: &Path,
29+
issuer_name: &str,
30+
issuer_directory: &str,
31+
) -> Result<AccountBootstrap, AcmeAccountStoreError> {
32+
let credentials_path = account_credentials_path(storage, issuer_name);
33+
let directory =
34+
credentials_path
35+
.path
36+
.parent()
37+
.ok_or_else(|| AcmeAccountStoreError::UnsafePath {
38+
path: credentials_path.path.clone(),
39+
message: "credentials path has no parent directory".to_owned(),
40+
})?;
41+
ensure_safe_account_directory(directory)?;
42+
let mutation_lock = AcmeMutationLock::acquire(directory)
43+
.map_err(|error| account_store_io_error(&directory.join(".fluxheim-acme.lock"), error))?;
44+
let pending_path = directory.join(ACCOUNT_BOOTSTRAP_PENDING_FILE);
45+
let credentials = load_account_credentials_locked(&credentials_path.path, directory, true)?;
46+
let pending = read_pending_key(&pending_path, issuer_directory)?;
47+
48+
if let Some(credentials) = credentials {
49+
if let Some(pending) = pending {
50+
let matches = pending.with_secret(|pending| {
51+
pending
52+
.ct_eq(credentials.private_key().secret_pkcs8_der())
53+
.declassify("ACME bootstrap key recovery result is public")
54+
});
55+
if !matches {
56+
return Err(AcmeAccountStoreError::UnsafePath {
57+
path: pending_path,
58+
message: "pending bootstrap key does not match active account credentials"
59+
.to_owned(),
60+
});
61+
}
62+
sanitize_and_remove_pending_key(directory, &pending_path)?;
63+
}
64+
return Ok(AccountBootstrap::Existing(credentials));
65+
}
66+
67+
let (key_der, initial_key, recovered) = match pending {
68+
Some(key_der) => (key_der, None, true),
69+
None => {
70+
let (key, generated) = instant_acme::Key::generate_pkcs8().map_err(|error| {
71+
AcmeAccountStoreError::UnsafePath {
72+
path: pending_path.clone(),
73+
message: format!("failed to generate pending account key: {error}"),
74+
}
75+
})?;
76+
let key_der = SecretVec::from_slice(generated.secret_pkcs8_der());
77+
write_pending_key(directory, &pending_path, issuer_directory, &key_der)?;
78+
(key_der, Some(key), false)
79+
}
80+
};
81+
82+
Ok(AccountBootstrap::Pending(PendingAccountBootstrap {
83+
directory: directory.to_path_buf(),
84+
credentials_path,
85+
pending_path,
86+
key_der,
87+
initial_key,
88+
recovered,
89+
_lock: mutation_lock,
90+
}))
91+
}
92+
93+
impl PendingAccountBootstrap {
94+
pub(crate) fn recovered(&self) -> bool {
95+
self.recovered
96+
}
97+
98+
pub(crate) fn key_pair(
99+
&mut self,
100+
) -> Result<(instant_acme::Key, PrivatePkcs8KeyDer<'static>), AcmeAccountStoreError> {
101+
self.key_der.with_secret(|key_der| {
102+
let key = if let Some(key) = self.initial_key.take() {
103+
key
104+
} else {
105+
instant_acme::Key::from_pkcs8_der(PrivatePkcs8KeyDer::from(key_der.to_vec()))
106+
.map_err(|_| AcmeAccountStoreError::UnsafePath {
107+
path: self.pending_path.clone(),
108+
message: "pending account key is not valid PKCS#8 P-256 material"
109+
.to_owned(),
110+
})?
111+
};
112+
Ok((key, PrivatePkcs8KeyDer::from(key_der.to_vec())))
113+
})
114+
}
115+
116+
pub(crate) fn existing_key_pair(
117+
&mut self,
118+
) -> Result<(instant_acme::Key, PrivateKeyDer<'static>), AcmeAccountStoreError> {
119+
let (key, key_der) = self.key_pair()?;
120+
Ok((key, PrivateKeyDer::Pkcs8(key_der)))
121+
}
122+
123+
pub(crate) fn promote(
124+
self,
125+
credentials: &instant_acme::AccountCredentials,
126+
) -> Result<(), AcmeAccountStoreError> {
127+
let matches = self.key_der.with_secret(|pending| {
128+
pending
129+
.ct_eq(credentials.private_key().secret_pkcs8_der())
130+
.declassify("ACME bootstrap credential key match result is public")
131+
});
132+
if !matches {
133+
return Err(AcmeAccountStoreError::UnsafePath {
134+
path: self.credentials_path.path.clone(),
135+
message: "remote account credentials do not contain the pending key".to_owned(),
136+
});
137+
}
138+
store_account_credentials_locked(&self.credentials_path, &self.directory, credentials)?;
139+
sanitize_and_remove_pending_key(&self.directory, &self.pending_path)
140+
}
141+
142+
#[cfg(test)]
143+
pub(crate) fn key_digest(&self) -> [u8; 32] {
144+
self.key_der.with_secret(|key| Sha256::digest(key).into())
145+
}
146+
147+
#[cfg(test)]
148+
pub(crate) fn persist_credentials_before_cleanup(
149+
&self,
150+
credentials: &instant_acme::AccountCredentials,
151+
) -> Result<(), AcmeAccountStoreError> {
152+
store_account_credentials_locked(&self.credentials_path, &self.directory, credentials)
153+
.map(drop)
154+
}
155+
}
156+
157+
fn write_pending_key(
158+
directory: &Path,
159+
pending_path: &Path,
160+
issuer_directory: &str,
161+
key_der: &SecretVec,
162+
) -> Result<(), AcmeAccountStoreError> {
163+
ensure_safe_account_destination(pending_path)?;
164+
let transaction =
165+
unique_transaction_id().map_err(|error| account_store_io_error(directory, error))?;
166+
let temporary = directory.join(format!(".credentials.bootstrap.{transaction}.tmp"));
167+
let issuer_hash = Sha256::digest(issuer_directory.as_bytes());
168+
let mut record = SecretVec::from_fn(
169+
ACCOUNT_BOOTSTRAP_MAGIC.len() + ACCOUNT_BOOTSTRAP_ISSUER_HASH_BYTES + key_der.len(),
170+
|_| 0,
171+
);
172+
record.with_secret_mut(|record| {
173+
let hash_start = ACCOUNT_BOOTSTRAP_MAGIC.len();
174+
let key_start = hash_start + ACCOUNT_BOOTSTRAP_ISSUER_HASH_BYTES;
175+
record[..hash_start].copy_from_slice(ACCOUNT_BOOTSTRAP_MAGIC);
176+
record[hash_start..key_start].copy_from_slice(&issuer_hash);
177+
key_der.with_secret(|key| record[key_start..].copy_from_slice(key));
178+
});
179+
let result = (|| {
180+
record.with_secret(|record| write_account_credentials_file(&temporary, record))?;
181+
fs::rename(&temporary, pending_path)
182+
.map_err(|error| account_store_io_error(pending_path, error))?;
183+
sync_account_directory(directory)
184+
})();
185+
if result.is_err()
186+
&& let Err(error) = sanitize_and_remove_pending_key(directory, &temporary)
187+
{
188+
log::error!(
189+
target: "fluxheim::security",
190+
"failed to sanitize pending ACME account-key temporary file: {error}"
191+
);
192+
}
193+
result
194+
}
195+
196+
fn read_pending_key(
197+
pending_path: &Path,
198+
issuer_directory: &str,
199+
) -> Result<Option<SecretVec>, AcmeAccountStoreError> {
200+
let metadata = match fs::symlink_metadata(pending_path) {
201+
Ok(metadata) => metadata,
202+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
203+
Err(error) => return Err(account_store_io_error(pending_path, error)),
204+
};
205+
if metadata.file_type().is_symlink() || !metadata.is_file() {
206+
return Err(AcmeAccountStoreError::UnsafePath {
207+
path: pending_path.to_path_buf(),
208+
message: "pending account key is not a regular file".to_owned(),
209+
});
210+
}
211+
let header_len = ACCOUNT_BOOTSTRAP_MAGIC.len() + ACCOUNT_BOOTSTRAP_ISSUER_HASH_BYTES;
212+
if metadata.len() <= header_len as u64 || metadata.len() > MAX_ACCOUNT_BOOTSTRAP_BYTES {
213+
return Err(AcmeAccountStoreError::Oversized {
214+
path: pending_path.to_path_buf(),
215+
max_bytes: MAX_ACCOUNT_BOOTSTRAP_BYTES,
216+
});
217+
}
218+
let admitted =
219+
usize::try_from(metadata.len()).map_err(|_| AcmeAccountStoreError::Oversized {
220+
path: pending_path.to_path_buf(),
221+
max_bytes: MAX_ACCOUNT_BOOTSTRAP_BYTES,
222+
})?;
223+
let mut file = open_regular_account_credentials_file(pending_path)
224+
.map_err(|error| account_store_io_error(pending_path, error))?;
225+
let mut record = SecretVec::from_fn(admitted, |_| 0);
226+
record
227+
.with_secret_mut(|record| file.read_exact(record))
228+
.map_err(|error| account_store_io_error(pending_path, error))?;
229+
let mut growth_probe = [0_u8; 1];
230+
let grew = file
231+
.read(&mut growth_probe)
232+
.map_err(|error| account_store_io_error(pending_path, error))?
233+
!= 0;
234+
sanitization::SecureSanitize::secure_sanitize(&mut growth_probe);
235+
if grew {
236+
return Err(AcmeAccountStoreError::Oversized {
237+
path: pending_path.to_path_buf(),
238+
max_bytes: MAX_ACCOUNT_BOOTSTRAP_BYTES,
239+
});
240+
}
241+
242+
record
243+
.with_secret(|record| {
244+
let hash_start = ACCOUNT_BOOTSTRAP_MAGIC.len();
245+
let key_start = hash_start + ACCOUNT_BOOTSTRAP_ISSUER_HASH_BYTES;
246+
if record[..hash_start] != *ACCOUNT_BOOTSTRAP_MAGIC {
247+
return Err(AcmeAccountStoreError::UnsafePath {
248+
path: pending_path.to_path_buf(),
249+
message: "pending account key has an invalid format".to_owned(),
250+
});
251+
}
252+
let expected_hash = Sha256::digest(issuer_directory.as_bytes());
253+
let issuer_matches = record[hash_start..key_start]
254+
.ct_eq(&expected_hash)
255+
.declassify("ACME bootstrap issuer binding result is public");
256+
if !issuer_matches {
257+
return Err(AcmeAccountStoreError::UnsafePath {
258+
path: pending_path.to_path_buf(),
259+
message: "pending account key belongs to a different issuer directory"
260+
.to_owned(),
261+
});
262+
}
263+
Ok(SecretVec::from_slice(&record[key_start..]))
264+
})
265+
.map(Some)
266+
}
267+
268+
fn sanitize_and_remove_pending_key(
269+
directory: &Path,
270+
path: &Path,
271+
) -> Result<(), AcmeAccountStoreError> {
272+
let metadata = match fs::symlink_metadata(path) {
273+
Ok(metadata) => metadata,
274+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
275+
Err(error) => return Err(account_store_io_error(path, error)),
276+
};
277+
if metadata.file_type().is_symlink()
278+
|| !metadata.is_file()
279+
|| metadata.len() > MAX_ACCOUNT_BOOTSTRAP_BYTES
280+
{
281+
return Err(AcmeAccountStoreError::UnsafePath {
282+
path: path.to_path_buf(),
283+
message: "pending account key cannot be safely sanitized".to_owned(),
284+
});
285+
}
286+
let mut options = fs::OpenOptions::new();
287+
options.write(true);
288+
#[cfg(target_os = "linux")]
289+
{
290+
use std::os::unix::fs::OpenOptionsExt as _;
291+
options.custom_flags(super::UNIX_O_NOFOLLOW);
292+
}
293+
let mut file = options
294+
.open(path)
295+
.map_err(|error| account_store_io_error(path, error))?;
296+
let zeros = [0_u8; 512];
297+
let mut remaining = metadata.len();
298+
while remaining != 0 {
299+
let length = usize::try_from(remaining.min(zeros.len() as u64)).map_err(|_| {
300+
AcmeAccountStoreError::Oversized {
301+
path: path.to_path_buf(),
302+
max_bytes: MAX_ACCOUNT_BOOTSTRAP_BYTES,
303+
}
304+
})?;
305+
file.write_all(&zeros[..length])
306+
.map_err(|error| account_store_io_error(path, error))?;
307+
remaining -= length as u64;
308+
}
309+
file.sync_all()
310+
.map_err(|error| account_store_io_error(path, error))?;
311+
file.set_len(0)
312+
.map_err(|error| account_store_io_error(path, error))?;
313+
file.sync_all()
314+
.map_err(|error| account_store_io_error(path, error))?;
315+
drop(file);
316+
fs::remove_file(path).map_err(|error| account_store_io_error(path, error))?;
317+
sync_account_directory(directory)
318+
}

0 commit comments

Comments
 (0)