Skip to content

Commit 84efbdf

Browse files
committed
Bound snapshot generation witness scans
1 parent 628a765 commit 84efbdf

9 files changed

Lines changed: 303 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ behavior when the change improves security or project direction.
5555
- Return snapshot crypto-provider failures to the administrative caller instead
5656
of aborting the process, and reject missing or replayed generation state when
5757
retained snapshot metadata proves a higher generation.
58+
- Verify generation freshness through bounded per-snapshot HMAC witnesses rather
59+
than rereading and hashing every retained config under the store lock, and
60+
remove the snapshot crate's unused direct logging dependency.
5861
- Make OpenSSL cipher allow-lists deterministic across protocol families:
5962
omitting all TLS 1.2 or TLS 1.3 suites now disables that protocol version
6063
instead of retaining Mozilla acceptor defaults. Use the current Mozilla v5

Cargo.lock

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

crates/fluxheim-snapshot/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ description = "Internal Fluxheim configuration snapshot store."
99

1010
[dependencies]
1111
fluxheim-config = { path = "../fluxheim-config" }
12-
log = "0.4.33"
1312
rustix = { version = "1.1.4", features = ["fs"] }
1413
sanitization = { version = "1.2.4", features = ["alloc"] }
1514
serde = { version = "1.0.228", features = ["derive"] }

crates/fluxheim-snapshot/src/generation.rs

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
use std::fs;
2+
13
use serde::{Deserialize, Serialize};
24

3-
use crate::integrity::GENERATION_MAC_LABEL;
5+
use crate::integrity::{
6+
GENERATION_MAC_LABEL, MAX_INTEGRITY_MANIFEST_BYTES, SnapshotIntegrityManifest,
7+
};
8+
use crate::metadata::{
9+
MAX_SNAPSHOT_METADATA_BYTES, SnapshotMetadata, validate_snapshot_id, validate_snapshot_metadata,
10+
};
411
use crate::store::{SnapshotError, SnapshotStore};
5-
use crate::store_fs::{read_optional_regular_file_to_string_with_limit, write_atomically};
12+
use crate::store_fs::{
13+
read_optional_regular_file_to_string_with_limit, read_regular_file_to_string_with_limit,
14+
regular_snapshot_file_exists, write_atomically,
15+
};
616

717
const MAX_GENERATION_STATE_BYTES: usize = 4096;
818

@@ -112,11 +122,67 @@ impl SnapshotStore {
112122
}
113123

114124
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))
125+
if !self.safe_existing_configs_dir()? {
126+
return Ok(0);
127+
}
128+
if let Some(key) = self.integrity.as_deref() {
129+
return self.observed_authenticated_generation(key);
130+
}
131+
self.observed_unverified_generation()
132+
}
133+
134+
fn observed_authenticated_generation(
135+
&self,
136+
key: &crate::integrity::SnapshotIntegrityKey,
137+
) -> Result<u64, SnapshotError> {
138+
let mut maximum = 0;
139+
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {
140+
let path = entry.map_err(SnapshotError::Io)?.path();
141+
if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
142+
continue;
143+
}
144+
let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
145+
return Err(SnapshotError::GenerationStateInvalid);
146+
};
147+
if id.ends_with(".meta") || id.ends_with(".integrity") {
148+
continue;
149+
}
150+
validate_snapshot_id(id).map_err(|_| SnapshotError::GenerationStateInvalid)?;
151+
if !regular_snapshot_file_exists(&path)? {
152+
return Err(SnapshotError::GenerationStateInvalid);
153+
}
154+
let raw = read_regular_file_to_string_with_limit(
155+
&self.integrity_path(id),
156+
MAX_INTEGRITY_MANIFEST_BYTES,
157+
)?;
158+
let manifest: SnapshotIntegrityManifest =
159+
toml::from_str(&raw).map_err(|_| SnapshotError::GenerationStateInvalid)?;
160+
let generation = key
161+
.verify_generation_witness(id, &manifest)
162+
.map_err(|_| SnapshotError::GenerationStateInvalid)?;
163+
maximum = maximum.max(generation);
164+
}
165+
Ok(maximum)
166+
}
167+
168+
fn observed_unverified_generation(&self) -> Result<u64, SnapshotError> {
169+
let mut maximum = 0;
170+
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {
171+
let path = entry.map_err(SnapshotError::Io)?.path();
172+
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
173+
return Err(SnapshotError::GenerationStateInvalid);
174+
};
175+
let Some(id) = name.strip_suffix(".meta.toml") else {
176+
continue;
177+
};
178+
validate_snapshot_id(id).map_err(|_| SnapshotError::GenerationStateInvalid)?;
179+
let raw = read_regular_file_to_string_with_limit(&path, MAX_SNAPSHOT_METADATA_BYTES)?;
180+
let metadata: SnapshotMetadata =
181+
toml::from_str(&raw).map_err(|_| SnapshotError::GenerationStateInvalid)?;
182+
validate_snapshot_metadata(&metadata, id)
183+
.map_err(|_| SnapshotError::GenerationStateInvalid)?;
184+
maximum = maximum.max(metadata.generation);
185+
}
186+
Ok(maximum)
121187
}
122188
}

crates/fluxheim-snapshot/src/integrity.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ use crate::store_fs::require_private_regular_file;
1212
pub(crate) const MAX_INTEGRITY_KEY_BYTES: u64 = 4096;
1313
const MIN_INTEGRITY_KEY_BYTES: usize = 32;
1414
const SNAPSHOT_MAC_LABEL: &[u8] = b"fluxheim-snapshot-v1\0";
15+
const GENERATION_WITNESS_MAC_LABEL: &[u8] = b"fluxheim-snapshot-generation-witness-v1\0";
1516
const RECOVERY_MAC_LABEL: &[u8] = b"fluxheim-snapshot-recovery-v1\0";
1617
pub(crate) const GENERATION_MAC_LABEL: &[u8] = b"fluxheim-snapshot-generation-v1\0";
1718
pub(crate) const PRUNE_BOUNDARY_MAC_LABEL: &[u8] = b"fluxheim-snapshot-prune-boundary-v1\0";
19+
pub(crate) const MAX_INTEGRITY_MANIFEST_BYTES: u64 = 4096;
1820

1921
pub trait SnapshotCryptoProvider: std::fmt::Debug + Send + Sync {
2022
fn label(&self) -> &'static str;
@@ -38,6 +40,8 @@ pub(crate) struct SnapshotIntegrityManifest {
3840
pub key_id: String,
3941
pub config_sha256: String,
4042
pub metadata_hmac_sha256: String,
43+
pub generation: u64,
44+
pub generation_hmac_sha256: String,
4145
}
4246

4347
impl SnapshotIntegrityKey {
@@ -89,17 +93,21 @@ impl SnapshotIntegrityKey {
8993
id: &str,
9094
config: &[u8],
9195
metadata: &[u8],
96+
generation: u64,
9297
) -> Result<SnapshotIntegrityManifest, SnapshotError> {
9398
let config_digest = self
9499
.provider
95100
.sha256(&[config])
96101
.map_err(SnapshotError::CryptoProvider)?;
97102
let signature = self.sign_result(id, config, metadata)?;
103+
let generation_signature = self.sign_generation_witness(id, generation)?;
98104
Ok(SnapshotIntegrityManifest {
99105
algorithm: "hmac-sha256".to_owned(),
100106
key_id: self.key_id.clone(),
101107
config_sha256: hex(&config_digest),
102108
metadata_hmac_sha256: hex(&signature),
109+
generation,
110+
generation_hmac_sha256: hex(&generation_signature),
103111
})
104112
}
105113

@@ -153,6 +161,7 @@ impl SnapshotIntegrityKey {
153161
id: &str,
154162
config: &[u8],
155163
metadata: &[u8],
164+
generation: u64,
156165
manifest: &SnapshotIntegrityManifest,
157166
) -> Result<(), SnapshotError> {
158167
if manifest.algorithm != "hmac-sha256" || manifest.key_id != self.key_id {
@@ -166,6 +175,10 @@ impl SnapshotIntegrityKey {
166175
if config_digest != manifest.config_sha256 {
167176
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
168177
}
178+
if manifest.generation != generation {
179+
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
180+
}
181+
self.verify_generation_witness(id, manifest)?;
169182
let expected = decode_hex_32(&manifest.metadata_hmac_sha256)
170183
.ok_or_else(|| SnapshotError::IntegrityVerificationFailed { id: id.to_owned() })?;
171184
if self
@@ -179,6 +192,49 @@ impl SnapshotIntegrityKey {
179192
}
180193
}
181194

195+
pub(crate) fn verify_generation_witness(
196+
&self,
197+
id: &str,
198+
manifest: &SnapshotIntegrityManifest,
199+
) -> Result<u64, SnapshotError> {
200+
crate::metadata::validate_snapshot_id(id)?;
201+
if manifest.algorithm != "hmac-sha256" || manifest.key_id != self.key_id {
202+
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
203+
}
204+
let expected = decode_hex_32(&manifest.generation_hmac_sha256)
205+
.ok_or_else(|| SnapshotError::IntegrityVerificationFailed { id: id.to_owned() })?;
206+
if self
207+
.sign_generation_witness(id, manifest.generation)?
208+
.ct_eq(&expected)
209+
.declassify("snapshot generation witness result is public")
210+
{
211+
Ok(manifest.generation)
212+
} else {
213+
Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() })
214+
}
215+
}
216+
217+
fn sign_generation_witness(
218+
&self,
219+
id: &str,
220+
generation: u64,
221+
) -> Result<[u8; 32], SnapshotError> {
222+
let id_length = encoded_length(id.len())?;
223+
self.secret
224+
.with_secret(|secret| {
225+
self.provider.hmac_sha256(
226+
secret,
227+
&[
228+
GENERATION_WITNESS_MAC_LABEL,
229+
&id_length,
230+
id.as_bytes(),
231+
&generation.to_be_bytes(),
232+
],
233+
)
234+
})
235+
.map_err(SnapshotError::CryptoProvider)
236+
}
237+
182238
fn sign_result(
183239
&self,
184240
id: &str,

crates/fluxheim-snapshot/src/metadata.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::store::SnapshotError;
44

55
pub const MAX_SNAPSHOT_ID_BYTES: usize = 128;
66
pub const MAX_SNAPSHOT_MESSAGE_BYTES: usize = 4096;
7+
pub(crate) const MAX_SNAPSHOT_METADATA_BYTES: u64 = 16 * 1024;
78

89
#[derive(Debug, Clone, Default, Eq, PartialEq, Deserialize, Serialize)]
910
#[serde(deny_unknown_fields)]

0 commit comments

Comments
 (0)