Skip to content

Commit 5daa043

Browse files
committed
Migrate legacy snapshot manifests safely
1 parent 84efbdf commit 5daa043

5 files changed

Lines changed: 190 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ behavior when the change improves security or project direction.
5858
- Verify generation freshness through bounded per-snapshot HMAC witnesses rather
5959
than rereading and hashing every retained config under the store lock, and
6060
remove the snapshot crate's unused direct logging dependency.
61+
- Preserve authenticated snapshot manifests created before generation witnesses:
62+
legacy manifests remain fully verifiable for current, rollback, and doctor
63+
operations and are atomically migrated after verification during the next
64+
locked snapshot creation.
6165
- Make OpenSSL cipher allow-lists deterministic across protocol families:
6266
omitting all TLS 1.2 or TLS 1.3 suites now disables that protocol version
6367
instead of retaining Mozilla acceptor defaults. Use the current Mozilla v5

crates/fluxheim-snapshot/src/generation.rs

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ use std::fs;
33
use serde::{Deserialize, Serialize};
44

55
use crate::integrity::{
6-
GENERATION_MAC_LABEL, MAX_INTEGRITY_MANIFEST_BYTES, SnapshotIntegrityManifest,
6+
GENERATION_MAC_LABEL, MAX_INTEGRITY_MANIFEST_BYTES, SnapshotIntegrityKey,
7+
SnapshotIntegrityManifest,
78
};
89
use crate::metadata::{
910
MAX_SNAPSHOT_METADATA_BYTES, SnapshotMetadata, validate_snapshot_id, validate_snapshot_metadata,
@@ -26,7 +27,7 @@ struct GenerationState {
2627

2728
impl SnapshotStore {
2829
pub(crate) fn allocate_generation_unlocked(&self) -> Result<u64, SnapshotError> {
29-
let observed_max = self.observed_max_generation()?;
30+
let observed_max = self.observed_max_generation(true)?;
3031
let current = match read_optional_regular_file_to_string_with_limit(
3132
&self.generation_path(),
3233
MAX_GENERATION_STATE_BYTES as u64,
@@ -50,7 +51,7 @@ impl SnapshotStore {
5051
}
5152

5253
pub(crate) fn verify_generation_state(&self) -> Result<(), SnapshotError> {
53-
let observed_max = self.observed_max_generation()?;
54+
let observed_max = self.observed_max_generation(false)?;
5455
let Some(raw) = read_optional_regular_file_to_string_with_limit(
5556
&self.generation_path(),
5657
MAX_GENERATION_STATE_BYTES as u64,
@@ -121,19 +122,20 @@ impl SnapshotStore {
121122
self.root().join("generation.toml")
122123
}
123124

124-
fn observed_max_generation(&self) -> Result<u64, SnapshotError> {
125+
fn observed_max_generation(&self, migrate_legacy: bool) -> Result<u64, SnapshotError> {
125126
if !self.safe_existing_configs_dir()? {
126127
return Ok(0);
127128
}
128129
if let Some(key) = self.integrity.as_deref() {
129-
return self.observed_authenticated_generation(key);
130+
return self.observed_authenticated_generation(key, migrate_legacy);
130131
}
131132
self.observed_unverified_generation()
132133
}
133134

134135
fn observed_authenticated_generation(
135136
&self,
136-
key: &crate::integrity::SnapshotIntegrityKey,
137+
key: &SnapshotIntegrityKey,
138+
migrate_legacy: bool,
137139
) -> Result<u64, SnapshotError> {
138140
let mut maximum = 0;
139141
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {
@@ -157,14 +159,61 @@ impl SnapshotStore {
157159
)?;
158160
let manifest: SnapshotIntegrityManifest =
159161
toml::from_str(&raw).map_err(|_| SnapshotError::GenerationStateInvalid)?;
160-
let generation = key
161-
.verify_generation_witness(id, &manifest)
162-
.map_err(|_| SnapshotError::GenerationStateInvalid)?;
162+
let generation = match &manifest {
163+
SnapshotIntegrityManifest::V2(witness) => {
164+
key.verify_generation_witness(id, witness)
165+
}
166+
SnapshotIntegrityManifest::V1(_) => {
167+
self.verify_legacy_generation(id, key, &manifest, migrate_legacy)
168+
}
169+
}
170+
.map_err(generation_scan_error)?;
163171
maximum = maximum.max(generation);
164172
}
165173
Ok(maximum)
166174
}
167175

176+
fn verify_legacy_generation(
177+
&self,
178+
id: &str,
179+
key: &SnapshotIntegrityKey,
180+
manifest: &SnapshotIntegrityManifest,
181+
migrate: bool,
182+
) -> Result<u64, SnapshotError> {
183+
let config = read_regular_file_to_string_with_limit(
184+
&self.config_path(id),
185+
crate::store_fs::MAX_SNAPSHOT_FILE_BYTES,
186+
)?;
187+
let metadata_raw = read_regular_file_to_string_with_limit(
188+
&self.metadata_path(id),
189+
MAX_SNAPSHOT_METADATA_BYTES,
190+
)?;
191+
let metadata: SnapshotMetadata =
192+
toml::from_str(&metadata_raw).map_err(SnapshotError::Decode)?;
193+
validate_snapshot_metadata(&metadata, id)?;
194+
key.verify(
195+
id,
196+
config.as_bytes(),
197+
metadata_raw.as_bytes(),
198+
metadata.generation,
199+
manifest,
200+
)?;
201+
if migrate {
202+
let witness = key.manifest(
203+
id,
204+
config.as_bytes(),
205+
metadata_raw.as_bytes(),
206+
metadata.generation,
207+
)?;
208+
let raw = toml::to_string_pretty(&witness).map_err(SnapshotError::Encode)?;
209+
if raw.len() as u64 > MAX_INTEGRITY_MANIFEST_BYTES {
210+
return Err(SnapshotError::GenerationStateInvalid);
211+
}
212+
write_atomically(&self.integrity_path(id), raw.as_bytes())?;
213+
}
214+
Ok(metadata.generation)
215+
}
216+
168217
fn observed_unverified_generation(&self) -> Result<u64, SnapshotError> {
169218
let mut maximum = 0;
170219
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {
@@ -186,3 +235,10 @@ impl SnapshotStore {
186235
Ok(maximum)
187236
}
188237
}
238+
239+
fn generation_scan_error(error: SnapshotError) -> SnapshotError {
240+
match error {
241+
SnapshotError::CryptoProvider(_) => error,
242+
_ => SnapshotError::GenerationStateInvalid,
243+
}
244+
}

crates/fluxheim-snapshot/src/integrity.rs

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,25 @@ pub(crate) struct SnapshotIntegrityKey {
3333
source_path: std::path::PathBuf,
3434
}
3535

36+
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
37+
#[serde(untagged)]
38+
pub(crate) enum SnapshotIntegrityManifest {
39+
V2(SnapshotIntegrityManifestV2),
40+
V1(SnapshotIntegrityManifestV1),
41+
}
42+
43+
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
44+
#[serde(deny_unknown_fields)]
45+
pub(crate) struct SnapshotIntegrityManifestV1 {
46+
pub algorithm: String,
47+
pub key_id: String,
48+
pub config_sha256: String,
49+
pub metadata_hmac_sha256: String,
50+
}
51+
3652
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
3753
#[serde(deny_unknown_fields)]
38-
pub(crate) struct SnapshotIntegrityManifest {
54+
pub(crate) struct SnapshotIntegrityManifestV2 {
3955
pub algorithm: String,
4056
pub key_id: String,
4157
pub config_sha256: String,
@@ -101,14 +117,14 @@ impl SnapshotIntegrityKey {
101117
.map_err(SnapshotError::CryptoProvider)?;
102118
let signature = self.sign_result(id, config, metadata)?;
103119
let generation_signature = self.sign_generation_witness(id, generation)?;
104-
Ok(SnapshotIntegrityManifest {
120+
Ok(SnapshotIntegrityManifest::V2(SnapshotIntegrityManifestV2 {
105121
algorithm: "hmac-sha256".to_owned(),
106122
key_id: self.key_id.clone(),
107123
config_sha256: hex(&config_digest),
108124
metadata_hmac_sha256: hex(&signature),
109125
generation,
110126
generation_hmac_sha256: hex(&generation_signature),
111-
})
127+
}))
112128
}
113129

114130
pub(crate) fn key_id(&self) -> &str {
@@ -164,22 +180,25 @@ impl SnapshotIntegrityKey {
164180
generation: u64,
165181
manifest: &SnapshotIntegrityManifest,
166182
) -> Result<(), SnapshotError> {
167-
if manifest.algorithm != "hmac-sha256" || manifest.key_id != self.key_id {
183+
let common = manifest.common();
184+
if common.algorithm != "hmac-sha256" || common.key_id != self.key_id {
168185
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
169186
}
170187
let config_digest = self
171188
.provider
172189
.sha256(&[config])
173190
.map_err(SnapshotError::CryptoProvider)
174191
.map(|digest| hex(&digest))?;
175-
if config_digest != manifest.config_sha256 {
192+
if config_digest != common.config_sha256 {
176193
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
177194
}
178-
if manifest.generation != generation {
179-
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
195+
if let SnapshotIntegrityManifest::V2(witness) = manifest {
196+
if witness.generation != generation {
197+
return Err(SnapshotError::IntegrityVerificationFailed { id: id.to_owned() });
198+
}
199+
self.verify_generation_witness(id, witness)?;
180200
}
181-
self.verify_generation_witness(id, manifest)?;
182-
let expected = decode_hex_32(&manifest.metadata_hmac_sha256)
201+
let expected = decode_hex_32(common.metadata_hmac_sha256)
183202
.ok_or_else(|| SnapshotError::IntegrityVerificationFailed { id: id.to_owned() })?;
184203
if self
185204
.sign_result(id, config, metadata)?
@@ -195,7 +214,7 @@ impl SnapshotIntegrityKey {
195214
pub(crate) fn verify_generation_witness(
196215
&self,
197216
id: &str,
198-
manifest: &SnapshotIntegrityManifest,
217+
manifest: &SnapshotIntegrityManifestV2,
199218
) -> Result<u64, SnapshotError> {
200219
crate::metadata::validate_snapshot_id(id)?;
201220
if manifest.algorithm != "hmac-sha256" || manifest.key_id != self.key_id {
@@ -263,6 +282,32 @@ impl SnapshotIntegrityKey {
263282
}
264283
}
265284

285+
impl SnapshotIntegrityManifest {
286+
fn common(&self) -> SnapshotIntegrityManifestCommon<'_> {
287+
match self {
288+
Self::V2(manifest) => SnapshotIntegrityManifestCommon {
289+
algorithm: &manifest.algorithm,
290+
key_id: &manifest.key_id,
291+
config_sha256: &manifest.config_sha256,
292+
metadata_hmac_sha256: &manifest.metadata_hmac_sha256,
293+
},
294+
Self::V1(manifest) => SnapshotIntegrityManifestCommon {
295+
algorithm: &manifest.algorithm,
296+
key_id: &manifest.key_id,
297+
config_sha256: &manifest.config_sha256,
298+
metadata_hmac_sha256: &manifest.metadata_hmac_sha256,
299+
},
300+
}
301+
}
302+
}
303+
304+
struct SnapshotIntegrityManifestCommon<'a> {
305+
algorithm: &'a str,
306+
key_id: &'a str,
307+
config_sha256: &'a str,
308+
metadata_hmac_sha256: &'a str,
309+
}
310+
266311
fn encoded_length(length: usize) -> Result<[u8; 8], SnapshotError> {
267312
u64::try_from(length)
268313
.map(u64::to_be_bytes)

crates/fluxheim-snapshot/src/snapshot_hardening_tests.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,65 @@ fn unverified_generation_scan_rejects_oversized_metadata() {
369369
assert_eq!(store.list_entries().unwrap().len(), 1);
370370
}
371371

372+
#[test]
373+
fn prior_manifest_format_loads_and_migrates_on_locked_creation() {
374+
let dir = TestDir::new("snapshot-manifest-v1-upgrade");
375+
let (store, _key) = authenticated_store(&dir);
376+
let first = store
377+
.snapshot_config(&Config::default(), Some("first"))
378+
.unwrap();
379+
let second = store
380+
.snapshot_config(&Config::default(), Some("second"))
381+
.unwrap();
382+
for id in [&first.id, &second.id] {
383+
rewrite_manifest_as_v1(&store, id);
384+
}
385+
386+
assert_eq!(store.current_snapshot().unwrap().unwrap().id, second.id);
387+
assert_eq!(
388+
store.rollback_candidate(None).unwrap().snapshot.id,
389+
first.id
390+
);
391+
assert!(store.doctor().unwrap().healthy);
392+
assert!(!manifest_raw(&store, &first.id).contains("generation_hmac_sha256"));
393+
394+
let third = store
395+
.snapshot_config(&Config::default(), Some("third"))
396+
.unwrap();
397+
398+
assert_eq!(third.metadata.generation, 3);
399+
for id in [&first.id, &second.id, &third.id] {
400+
let raw = manifest_raw(&store, id);
401+
assert!(raw.contains("generation_hmac_sha256"));
402+
assert_eq!(
403+
store.verify(id).unwrap(),
404+
crate::SnapshotIntegrityStatus::Authenticated
405+
);
406+
}
407+
}
408+
409+
fn rewrite_manifest_as_v1(store: &SnapshotStore, id: &str) {
410+
let path = store
411+
.root()
412+
.join("configs")
413+
.join(format!("{id}.integrity.toml"));
414+
let mut manifest: toml::Table =
415+
toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
416+
manifest.remove("generation");
417+
manifest.remove("generation_hmac_sha256");
418+
std::fs::write(path, toml::to_string_pretty(&manifest).unwrap()).unwrap();
419+
}
420+
421+
fn manifest_raw(store: &SnapshotStore, id: &str) -> String {
422+
std::fs::read_to_string(
423+
store
424+
.root()
425+
.join("configs")
426+
.join(format!("{id}.integrity.toml")),
427+
)
428+
.unwrap()
429+
}
430+
372431
fn authenticated_store(dir: &TestDir) -> (SnapshotStore, std::path::PathBuf) {
373432
let store = store_with_provider(dir, Arc::new(TestCryptoProvider));
374433
(store, dir.child("snapshot.key"))

docs/config-snapshots.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ Each authenticated integrity manifest includes a small generation witness, so
5050
freshness checks scan at most 4 KiB per retained manifest and do not reread or
5151
hash complete snapshot configurations while holding the store mutation lock.
5252
Unverified stores scan metadata with a 16 KiB per-file bound.
53+
Authenticated manifests created before generation witnesses remain readable.
54+
Fluxheim fully verifies their original config digest and metadata HMAC for
55+
current, rollback, and doctor operations. The next snapshot creation performs
56+
that same verification under the store lock and atomically replaces each legacy
57+
manifest with the witnessed format before publishing the new snapshot. A store
58+
with many legacy snapshots therefore pays one bounded upgrade scan that may read
59+
their configs; subsequent mutations use only the small witnesses.
5360
Fluxheim rejects a missing or lower valid counter when retained snapshot
5461
metadata proves a higher generation. An HMAC file cannot detect coordinated
5562
rollback of the complete store after every newer snapshot has been pruned,

0 commit comments

Comments
 (0)