Skip to content

Commit 628a765

Browse files
committed
Fail snapshot cryptography without aborting
1 parent 96ada14 commit 628a765

8 files changed

Lines changed: 219 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ behavior when the change improves security or project direction.
5252
- Route snapshot SHA-256 and HMAC-SHA-256 through Fluxheim's selected Ring,
5353
OpenSSL-FIPS, or AWS-LC-FIPS provider without duplicating snapshot plaintext
5454
into a concatenation buffer.
55+
- Return snapshot crypto-provider failures to the administrative caller instead
56+
of aborting the process, and reject missing or replayed generation state when
57+
retained snapshot metadata proves a higher generation.
5558
- Make OpenSSL cipher allow-lists deterministic across protocol families:
5659
omitting all TLS 1.2 or TLS 1.3 suites now disables that protocol version
5760
instead of retaining Mozilla acceptor defaults. Use the current Mozilla v5

crates/fluxheim-snapshot/src/generation.rs

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,18 @@ struct GenerationState {
1616

1717
impl SnapshotStore {
1818
pub(crate) fn allocate_generation_unlocked(&self) -> Result<u64, SnapshotError> {
19+
let observed_max = self.observed_max_generation()?;
1920
let current = match read_optional_regular_file_to_string_with_limit(
2021
&self.generation_path(),
2122
MAX_GENERATION_STATE_BYTES as u64,
2223
)? {
2324
Some(raw) => self.decode_generation_state(&raw)?,
24-
None => self
25-
.list()?
26-
.into_iter()
27-
.map(|snapshot| snapshot.metadata.generation)
28-
.max()
29-
.unwrap_or(0),
25+
None if observed_max == 0 => 0,
26+
None => return Err(SnapshotError::GenerationStateInvalid),
3027
};
28+
if current < observed_max {
29+
return Err(SnapshotError::GenerationStateInvalid);
30+
}
3131
let next = current
3232
.checked_add(1)
3333
.ok_or(SnapshotError::GenerationExhausted)?;
@@ -40,24 +40,34 @@ impl SnapshotStore {
4040
}
4141

4242
pub(crate) fn verify_generation_state(&self) -> Result<(), SnapshotError> {
43+
let observed_max = self.observed_max_generation()?;
4344
let Some(raw) = read_optional_regular_file_to_string_with_limit(
4445
&self.generation_path(),
4546
MAX_GENERATION_STATE_BYTES as u64,
4647
)?
4748
else {
48-
return Ok(());
49+
return if observed_max == 0 {
50+
Ok(())
51+
} else {
52+
Err(SnapshotError::GenerationStateInvalid)
53+
};
4954
};
50-
self.decode_generation_state(&raw).map(|_| ())
55+
let persisted = self.decode_generation_state(&raw)?;
56+
if persisted < observed_max {
57+
return Err(SnapshotError::GenerationStateInvalid);
58+
}
59+
Ok(())
5160
}
5261

5362
fn encode_generation_state(&self, generation: u64) -> Result<String, SnapshotError> {
5463
let payload = generation.to_be_bytes();
55-
let (key_id, hmac_sha256) = self.integrity.as_deref().map_or((None, None), |key| {
56-
(
64+
let (key_id, hmac_sha256) = match self.integrity.as_deref() {
65+
Some(key) => (
5766
Some(key.key_id().to_owned()),
58-
Some(key.sign_state(GENERATION_MAC_LABEL, &payload)),
59-
)
60-
});
67+
Some(key.sign_state(GENERATION_MAC_LABEL, &payload)?),
68+
),
69+
None => (None, None),
70+
};
6171
toml::to_string(&GenerationState {
6272
generation,
6373
key_id,
@@ -100,4 +110,13 @@ impl SnapshotStore {
100110
pub(crate) fn generation_path(&self) -> std::path::PathBuf {
101111
self.root().join("generation.toml")
102112
}
113+
114+
fn observed_max_generation(&self) -> Result<u64, SnapshotError> {
115+
Ok(self
116+
.list()?
117+
.into_iter()
118+
.map(|snapshot| snapshot.metadata.generation)
119+
.max()
120+
.unwrap_or(0))
121+
}
103122
}

crates/fluxheim-snapshot/src/integrity.rs

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,18 @@ impl SnapshotIntegrityKey {
8989
id: &str,
9090
config: &[u8],
9191
metadata: &[u8],
92-
) -> SnapshotIntegrityManifest {
93-
SnapshotIntegrityManifest {
92+
) -> Result<SnapshotIntegrityManifest, SnapshotError> {
93+
let config_digest = self
94+
.provider
95+
.sha256(&[config])
96+
.map_err(SnapshotError::CryptoProvider)?;
97+
let signature = self.sign_result(id, config, metadata)?;
98+
Ok(SnapshotIntegrityManifest {
9499
algorithm: "hmac-sha256".to_owned(),
95100
key_id: self.key_id.clone(),
96-
config_sha256: self
97-
.provider
98-
.sha256(&[config])
99-
.map(|digest| hex(&digest))
100-
.unwrap_or_else(|error| crypto_provider_abort(self.provider.label(), &error)),
101-
metadata_hmac_sha256: hex(&self.sign(id, config, metadata)),
102-
}
101+
config_sha256: hex(&config_digest),
102+
metadata_hmac_sha256: hex(&signature),
103+
})
103104
}
104105

105106
pub(crate) fn key_id(&self) -> &str {
@@ -110,7 +111,7 @@ impl SnapshotIntegrityKey {
110111
&self.source_path
111112
}
112113

113-
pub(crate) fn sign_recovery(&self, state: &[u8]) -> String {
114+
pub(crate) fn sign_recovery(&self, state: &[u8]) -> Result<String, SnapshotError> {
114115
self.sign_state(RECOVERY_MAC_LABEL, state)
115116
}
116117

@@ -121,14 +122,17 @@ impl SnapshotIntegrityKey {
121122
self.verify_state(RECOVERY_MAC_LABEL, state, &signature)
122123
}
123124

124-
pub(crate) fn sign_state(&self, label: &[u8], state: &[u8]) -> String {
125-
let length = (state.len() as u64).to_be_bytes();
126-
self.secret.with_secret(|secret| {
127-
self.provider
128-
.hmac_sha256(secret, &[label, &length, state])
129-
.map(|digest| hex(&digest))
130-
.unwrap_or_else(|error| crypto_provider_abort(self.provider.label(), &error))
131-
})
125+
pub(crate) fn sign_state(&self, label: &[u8], state: &[u8]) -> Result<String, SnapshotError> {
126+
let length = u64::try_from(state.len())
127+
.map_err(|_| SnapshotError::CryptoProvider("state length overflow".to_owned()))?
128+
.to_be_bytes();
129+
self.secret
130+
.with_secret(|secret| {
131+
self.provider
132+
.hmac_sha256(secret, &[label, &length, state])
133+
.map(|digest| hex(&digest))
134+
})
135+
.map_err(SnapshotError::CryptoProvider)
132136
}
133137

134138
pub(crate) fn verify_state(&self, label: &[u8], state: &[u8], signature: &[u8; 32]) -> bool {
@@ -175,22 +179,15 @@ impl SnapshotIntegrityKey {
175179
}
176180
}
177181

178-
fn sign(&self, id: &str, config: &[u8], metadata: &[u8]) -> [u8; 32] {
179-
self.sign_result(id, config, metadata)
180-
.unwrap_or_else(|error| {
181-
crypto_provider_abort(self.provider.label(), &error.to_string())
182-
})
183-
}
184-
185182
fn sign_result(
186183
&self,
187184
id: &str,
188185
config: &[u8],
189186
metadata: &[u8],
190187
) -> Result<[u8; 32], SnapshotError> {
191-
let id_length = (id.len() as u64).to_be_bytes();
192-
let config_length = (config.len() as u64).to_be_bytes();
193-
let metadata_length = (metadata.len() as u64).to_be_bytes();
188+
let id_length = encoded_length(id.len())?;
189+
let config_length = encoded_length(config.len())?;
190+
let metadata_length = encoded_length(metadata.len())?;
194191
self.secret
195192
.with_secret(|secret| {
196193
self.provider.hmac_sha256(
@@ -210,9 +207,10 @@ impl SnapshotIntegrityKey {
210207
}
211208
}
212209

213-
fn crypto_provider_abort(provider: &str, error: &str) -> ! {
214-
log::error!("fatal: snapshot cryptography failed through {provider}: {error}");
215-
std::process::abort()
210+
fn encoded_length(length: usize) -> Result<[u8; 8], SnapshotError> {
211+
u64::try_from(length)
212+
.map(u64::to_be_bytes)
213+
.map_err(|_| SnapshotError::CryptoProvider("field length overflow".to_owned()))
216214
}
217215

218216
fn hex(bytes: &[u8]) -> String {

crates/fluxheim-snapshot/src/prune_boundary.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,13 @@ impl SnapshotStore {
8282
records: records.iter().cloned().collect(),
8383
})
8484
.map_err(SnapshotError::Encode)?;
85-
let (key_id, hmac_sha256) = self.integrity.as_deref().map_or((None, None), |key| {
86-
(
85+
let (key_id, hmac_sha256) = match self.integrity.as_deref() {
86+
Some(key) => (
8787
Some(key.key_id().to_owned()),
88-
Some(key.sign_state(PRUNE_BOUNDARY_MAC_LABEL, records_toml.as_bytes())),
89-
)
90-
});
88+
Some(key.sign_state(PRUNE_BOUNDARY_MAC_LABEL, records_toml.as_bytes())?),
89+
),
90+
None => (None, None),
91+
};
9192
let raw = toml::to_string(&PersistedPruneBoundaries {
9293
records_toml,
9394
key_id,

crates/fluxheim-snapshot/src/recovery.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ impl SnapshotStore {
4343
let state_toml = toml::to_string_pretty(state).map_err(SnapshotError::Encode)?;
4444
let persisted = match self.integrity.as_deref() {
4545
Some(key) => PersistedRuntimeState {
46-
hmac_sha256: Some(key.sign_recovery(state_toml.as_bytes())),
46+
hmac_sha256: Some(key.sign_recovery(state_toml.as_bytes())?),
4747
key_id: Some(key.key_id().to_owned()),
4848
state_toml,
4949
},

crates/fluxheim-snapshot/src/snapshot_hardening_tests.rs

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ impl SnapshotCryptoProvider for TestCryptoProvider {
3939
}
4040
}
4141

42+
#[derive(Debug)]
43+
struct FailingCryptoProvider {
44+
fail_label: &'static [u8],
45+
}
46+
47+
impl SnapshotCryptoProvider for FailingCryptoProvider {
48+
fn label(&self) -> &'static str {
49+
"failing-test-provider"
50+
}
51+
52+
fn compliance_capable(&self) -> bool {
53+
false
54+
}
55+
56+
fn sha256(&self, chunks: &[&[u8]]) -> Result<[u8; 32], String> {
57+
TestCryptoProvider.sha256(chunks)
58+
}
59+
60+
fn hmac_sha256(&self, key: &[u8], chunks: &[&[u8]]) -> Result<[u8; 32], String> {
61+
if chunks
62+
.first()
63+
.is_some_and(|label| *label == self.fail_label)
64+
{
65+
return Err("injected cryptographic provider failure".to_owned());
66+
}
67+
TestCryptoProvider.hmac_sha256(key, chunks)
68+
}
69+
}
70+
4271
#[cfg(unix)]
4372
#[test]
4473
fn doctor_rechecks_integrity_key_permissions() {
@@ -103,17 +132,118 @@ fn authenticated_prune_boundary_rejects_tampering() {
103132
assert!(!store.doctor().unwrap().healthy);
104133
}
105134

135+
#[test]
136+
fn snapshot_generation_provider_failure_is_returned_without_abort() {
137+
let dir = TestDir::new("snapshot-provider-generation-failure");
138+
let store = store_with_provider(
139+
&dir,
140+
Arc::new(FailingCryptoProvider {
141+
fail_label: b"fluxheim-snapshot-generation-v1\0",
142+
}),
143+
);
144+
145+
assert!(matches!(
146+
store.snapshot_config(&Config::default(), None),
147+
Err(SnapshotError::CryptoProvider(_))
148+
));
149+
assert!(store.list().unwrap().is_empty());
150+
}
151+
152+
#[test]
153+
fn snapshot_manifest_provider_failure_is_returned_without_abort() {
154+
let dir = TestDir::new("snapshot-provider-manifest-failure");
155+
let store = store_with_provider(
156+
&dir,
157+
Arc::new(FailingCryptoProvider {
158+
fail_label: b"fluxheim-snapshot-v1\0",
159+
}),
160+
);
161+
162+
assert!(matches!(
163+
store.snapshot_config(&Config::default(), None),
164+
Err(SnapshotError::CryptoProvider(_))
165+
));
166+
assert!(store.list().unwrap().is_empty());
167+
assert!(store.current_id().unwrap().is_none());
168+
}
169+
170+
#[test]
171+
fn recovery_provider_failure_is_returned_without_abort() {
172+
let dir = TestDir::new("snapshot-provider-recovery-failure");
173+
let store = store_with_provider(
174+
&dir,
175+
Arc::new(FailingCryptoProvider {
176+
fail_label: b"fluxheim-snapshot-recovery-v1\0",
177+
}),
178+
);
179+
store.snapshot_config(&Config::default(), None).unwrap();
180+
181+
assert!(matches!(
182+
store.save_runtime_state(&crate::SnapshotRuntimeState::default()),
183+
Err(SnapshotError::CryptoProvider(_))
184+
));
185+
assert!(!store.root().join("self-healing.toml").exists());
186+
}
187+
188+
#[test]
189+
fn prune_provider_failure_preserves_snapshots_without_abort() {
190+
let dir = TestDir::new("snapshot-provider-prune-failure");
191+
let store = store_with_provider(
192+
&dir,
193+
Arc::new(FailingCryptoProvider {
194+
fail_label: b"fluxheim-snapshot-prune-boundary-v1\0",
195+
}),
196+
);
197+
store.snapshot_config(&Config::default(), None).unwrap();
198+
store.snapshot_config(&Config::default(), None).unwrap();
199+
store.snapshot_config(&Config::default(), None).unwrap();
200+
201+
assert!(matches!(
202+
store.prune(&SnapshotPruneOptions {
203+
keep: Some(0),
204+
older_than: None,
205+
protected_ids: Vec::new(),
206+
}),
207+
Err(SnapshotError::CryptoProvider(_))
208+
));
209+
assert_eq!(store.list().unwrap().len(), 3);
210+
}
211+
212+
#[test]
213+
fn generation_state_replay_and_removal_fail_before_publication() {
214+
let dir = TestDir::new("snapshot-generation-replay");
215+
let (store, _key) = authenticated_store(&dir);
216+
store.snapshot_config(&Config::default(), None).unwrap();
217+
let generation_path = safe_child_path(store.root(), "generation.toml");
218+
let generation_one = std::fs::read(&generation_path).unwrap();
219+
store.snapshot_config(&Config::default(), None).unwrap();
220+
221+
std::fs::write(&generation_path, &generation_one).unwrap();
222+
assert!(matches!(
223+
store.snapshot_config(&Config::default(), None),
224+
Err(SnapshotError::GenerationStateInvalid)
225+
));
226+
assert_eq!(store.list().unwrap().len(), 2);
227+
assert!(!store.doctor().unwrap().healthy);
228+
229+
std::fs::remove_file(&generation_path).unwrap();
230+
assert!(matches!(
231+
store.snapshot_config(&Config::default(), None),
232+
Err(SnapshotError::GenerationStateInvalid)
233+
));
234+
assert_eq!(store.list().unwrap().len(), 2);
235+
}
236+
106237
fn authenticated_store(dir: &TestDir) -> (SnapshotStore, std::path::PathBuf) {
238+
let store = store_with_provider(dir, Arc::new(TestCryptoProvider));
239+
(store, dir.child("snapshot.key"))
240+
}
241+
242+
fn store_with_provider(dir: &TestDir, provider: Arc<dyn SnapshotCryptoProvider>) -> SnapshotStore {
107243
let key = dir.child("snapshot.key");
108244
std::fs::write(&key, [11u8; 32]).unwrap();
109245
set_private_file(&key);
110-
let store = SnapshotStore::with_integrity_key_file(
111-
dir.child("store"),
112-
&key,
113-
Arc::new(TestCryptoProvider),
114-
)
115-
.unwrap();
116-
(store, key)
246+
SnapshotStore::with_integrity_key_file(dir.child("store"), &key, provider).unwrap()
117247
}
118248

119249
fn set_private_file(path: &std::path::Path) {

crates/fluxheim-snapshot/src/store.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ impl SnapshotStore {
9999
publish_transaction_file(&mut transaction, &config_path, raw_config.as_bytes())?;
100100
publish_transaction_file(&mut transaction, &metadata_path, raw_metadata.as_bytes())?;
101101
let integrity = if let Some(key) = self.integrity.as_deref() {
102-
let manifest = key.manifest(&id, raw_config.as_bytes(), raw_metadata.as_bytes());
102+
let manifest = key.manifest(&id, raw_config.as_bytes(), raw_metadata.as_bytes())?;
103103
let raw_manifest =
104104
toml::to_string_pretty(&manifest).map_err(SnapshotError::Encode)?;
105105
publish_transaction_file(

0 commit comments

Comments
 (0)