Skip to content

Commit 4b65921

Browse files
committed
Bootstrap legacy snapshot generations safely
1 parent 5daa043 commit 4b65921

5 files changed

Lines changed: 265 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ behavior when the change improves security or project direction.
6262
legacy manifests remain fully verifiable for current, rollback, and doctor
6363
operations and are atomically migrated after verification during the next
6464
locked snapshot creation.
65+
- Bootstrap authenticated generation state for a fully verified store whose
66+
retained manifests all predate generation witnesses. Fluxheim persists the
67+
authenticated high-water mark before migrating those manifests and publishing
68+
generation `max + 1`; missing state still fails closed for V2 or mixed stores.
6569
- Make OpenSSL cipher allow-lists deterministic across protocol families:
6670
omitting all TLS 1.2 or TLS 1.3 suites now disables that protocol version
6771
instead of retaining Mozilla acceptor defaults. Use the current Mozilla v5

crates/fluxheim-snapshot/src/generation.rs

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,25 @@ struct GenerationState {
2525
hmac_sha256: Option<String>,
2626
}
2727

28+
struct GenerationObservation {
29+
maximum: u64,
30+
legacy_ids: Vec<String>,
31+
allow_missing_state: bool,
32+
}
33+
2834
impl SnapshotStore {
2935
pub(crate) fn allocate_generation_unlocked(&self) -> Result<u64, SnapshotError> {
30-
let observed_max = self.observed_max_generation(true)?;
36+
let observation = self.observe_generations()?;
3137
let current = match read_optional_regular_file_to_string_with_limit(
3238
&self.generation_path(),
3339
MAX_GENERATION_STATE_BYTES as u64,
3440
)? {
3541
Some(raw) => self.decode_generation_state(&raw)?,
36-
None if observed_max == 0 => 0,
42+
None if observation.maximum == 0 => 0,
43+
None if observation.allow_missing_state => observation.maximum,
3744
None => return Err(SnapshotError::GenerationStateInvalid),
3845
};
39-
if current < observed_max {
46+
if current < observation.maximum {
4047
return Err(SnapshotError::GenerationStateInvalid);
4148
}
4249
let next = current
@@ -47,24 +54,29 @@ impl SnapshotStore {
4754
return Err(SnapshotError::GenerationStateInvalid);
4855
}
4956
write_atomically(&self.generation_path(), raw.as_bytes())?;
57+
if let Some(key) = self.integrity.as_deref() {
58+
for id in observation.legacy_ids {
59+
self.migrate_legacy_manifest(&id, key)?;
60+
}
61+
}
5062
Ok(next)
5163
}
5264

5365
pub(crate) fn verify_generation_state(&self) -> Result<(), SnapshotError> {
54-
let observed_max = self.observed_max_generation(false)?;
66+
let observation = self.observe_generations()?;
5567
let Some(raw) = read_optional_regular_file_to_string_with_limit(
5668
&self.generation_path(),
5769
MAX_GENERATION_STATE_BYTES as u64,
5870
)?
5971
else {
60-
return if observed_max == 0 {
72+
return if observation.maximum == 0 || observation.allow_missing_state {
6173
Ok(())
6274
} else {
6375
Err(SnapshotError::GenerationStateInvalid)
6476
};
6577
};
6678
let persisted = self.decode_generation_state(&raw)?;
67-
if persisted < observed_max {
79+
if persisted < observation.maximum {
6880
return Err(SnapshotError::GenerationStateInvalid);
6981
}
7082
Ok(())
@@ -122,22 +134,31 @@ impl SnapshotStore {
122134
self.root().join("generation.toml")
123135
}
124136

125-
fn observed_max_generation(&self, migrate_legacy: bool) -> Result<u64, SnapshotError> {
137+
fn observe_generations(&self) -> Result<GenerationObservation, SnapshotError> {
126138
if !self.safe_existing_configs_dir()? {
127-
return Ok(0);
139+
return Ok(GenerationObservation {
140+
maximum: 0,
141+
legacy_ids: Vec::new(),
142+
allow_missing_state: false,
143+
});
128144
}
129145
if let Some(key) = self.integrity.as_deref() {
130-
return self.observed_authenticated_generation(key, migrate_legacy);
146+
return self.observe_authenticated_generations(key);
131147
}
132-
self.observed_unverified_generation()
148+
Ok(GenerationObservation {
149+
maximum: self.observed_unverified_generation()?,
150+
legacy_ids: Vec::new(),
151+
allow_missing_state: false,
152+
})
133153
}
134154

135-
fn observed_authenticated_generation(
155+
fn observe_authenticated_generations(
136156
&self,
137157
key: &SnapshotIntegrityKey,
138-
migrate_legacy: bool,
139-
) -> Result<u64, SnapshotError> {
158+
) -> Result<GenerationObservation, SnapshotError> {
140159
let mut maximum = 0;
160+
let mut legacy_ids = Vec::new();
161+
let mut snapshot_count = 0_usize;
141162
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {
142163
let path = entry.map_err(SnapshotError::Io)?.path();
143164
if path.extension().and_then(|extension| extension.to_str()) != Some("toml") {
@@ -149,6 +170,7 @@ impl SnapshotStore {
149170
if id.ends_with(".meta") || id.ends_with(".integrity") {
150171
continue;
151172
}
173+
snapshot_count = snapshot_count.saturating_add(1);
152174
validate_snapshot_id(id).map_err(|_| SnapshotError::GenerationStateInvalid)?;
153175
if !regular_snapshot_file_exists(&path)? {
154176
return Err(SnapshotError::GenerationStateInvalid);
@@ -164,21 +186,27 @@ impl SnapshotStore {
164186
key.verify_generation_witness(id, witness)
165187
}
166188
SnapshotIntegrityManifest::V1(_) => {
167-
self.verify_legacy_generation(id, key, &manifest, migrate_legacy)
189+
let generation = self.verify_legacy_generation(id, key, &manifest)?;
190+
legacy_ids.push(id.to_owned());
191+
Ok(generation)
168192
}
169193
}
170194
.map_err(generation_scan_error)?;
171195
maximum = maximum.max(generation);
172196
}
173-
Ok(maximum)
197+
let allow_missing_state = snapshot_count != 0 && legacy_ids.len() == snapshot_count;
198+
Ok(GenerationObservation {
199+
maximum,
200+
legacy_ids,
201+
allow_missing_state,
202+
})
174203
}
175204

176205
fn verify_legacy_generation(
177206
&self,
178207
id: &str,
179208
key: &SnapshotIntegrityKey,
180209
manifest: &SnapshotIntegrityManifest,
181-
migrate: bool,
182210
) -> Result<u64, SnapshotError> {
183211
let config = read_regular_file_to_string_with_limit(
184212
&self.config_path(id),
@@ -198,22 +226,54 @@ impl SnapshotStore {
198226
metadata.generation,
199227
manifest,
200228
)?;
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-
}
214229
Ok(metadata.generation)
215230
}
216231

232+
fn migrate_legacy_manifest(
233+
&self,
234+
id: &str,
235+
key: &SnapshotIntegrityKey,
236+
) -> Result<(), SnapshotError> {
237+
let raw = read_regular_file_to_string_with_limit(
238+
&self.integrity_path(id),
239+
MAX_INTEGRITY_MANIFEST_BYTES,
240+
)?;
241+
let manifest: SnapshotIntegrityManifest =
242+
toml::from_str(&raw).map_err(SnapshotError::Decode)?;
243+
if matches!(manifest, SnapshotIntegrityManifest::V2(_)) {
244+
return Ok(());
245+
}
246+
let config = read_regular_file_to_string_with_limit(
247+
&self.config_path(id),
248+
crate::store_fs::MAX_SNAPSHOT_FILE_BYTES,
249+
)?;
250+
let metadata_raw = read_regular_file_to_string_with_limit(
251+
&self.metadata_path(id),
252+
MAX_SNAPSHOT_METADATA_BYTES,
253+
)?;
254+
let metadata: SnapshotMetadata =
255+
toml::from_str(&metadata_raw).map_err(SnapshotError::Decode)?;
256+
validate_snapshot_metadata(&metadata, id)?;
257+
key.verify(
258+
id,
259+
config.as_bytes(),
260+
metadata_raw.as_bytes(),
261+
metadata.generation,
262+
&manifest,
263+
)?;
264+
let witness = key.manifest(
265+
id,
266+
config.as_bytes(),
267+
metadata_raw.as_bytes(),
268+
metadata.generation,
269+
)?;
270+
let raw = toml::to_string_pretty(&witness).map_err(SnapshotError::Encode)?;
271+
if raw.len() as u64 > MAX_INTEGRITY_MANIFEST_BYTES {
272+
return Err(SnapshotError::GenerationStateInvalid);
273+
}
274+
write_atomically(&self.integrity_path(id), raw.as_bytes())
275+
}
276+
217277
fn observed_unverified_generation(&self) -> Result<u64, SnapshotError> {
218278
let mut maximum = 0;
219279
for entry in fs::read_dir(self.configs_dir()).map_err(SnapshotError::Io)? {

crates/fluxheim-snapshot/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ mod store_support;
2121
mod operations_tests;
2222
#[cfg(test)]
2323
mod snapshot_hardening_tests;
24+
#[cfg(test)]
25+
mod snapshot_upgrade_tests;
2426

2527
pub use integrity::SnapshotCryptoProvider;
2628
pub use metadata::{MAX_SNAPSHOT_MESSAGE_BYTES, SnapshotMetadata};
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
use std::path::{Path, PathBuf};
2+
use std::sync::Arc;
3+
4+
use fluxheim_common::test_support::{safe_child_path, unique_temp_path};
5+
use fluxheim_config::Config;
6+
7+
use crate::{SnapshotCryptoProvider, SnapshotStore};
8+
9+
#[derive(Debug)]
10+
struct TestCryptoProvider;
11+
12+
impl SnapshotCryptoProvider for TestCryptoProvider {
13+
fn label(&self) -> &'static str {
14+
"snapshot-upgrade-test"
15+
}
16+
17+
fn compliance_capable(&self) -> bool {
18+
false
19+
}
20+
21+
fn sha256(&self, chunks: &[&[u8]]) -> Result<[u8; 32], String> {
22+
let mut context = ring::digest::Context::new(&ring::digest::SHA256);
23+
for chunk in chunks {
24+
context.update(chunk);
25+
}
26+
let mut output = [0_u8; 32];
27+
output.copy_from_slice(context.finish().as_ref());
28+
Ok(output)
29+
}
30+
31+
fn hmac_sha256(&self, key: &[u8], chunks: &[&[u8]]) -> Result<[u8; 32], String> {
32+
let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, key);
33+
let mut context = ring::hmac::Context::with_key(&key);
34+
for chunk in chunks {
35+
context.update(chunk);
36+
}
37+
let mut output = [0_u8; 32];
38+
output.copy_from_slice(context.sign().as_ref());
39+
Ok(output)
40+
}
41+
}
42+
43+
#[test]
44+
fn pre_generation_authenticated_store_bootstraps_and_migrates() {
45+
let dir = TestDir::new("snapshot-pre-generation-bootstrap");
46+
let store = authenticated_store(&dir);
47+
let first = store
48+
.snapshot_config(&Config::default(), Some("first"))
49+
.unwrap();
50+
let second = store
51+
.snapshot_config(&Config::default(), Some("second"))
52+
.unwrap();
53+
for id in [&first.id, &second.id] {
54+
rewrite_manifest_as_v1(&store, id);
55+
}
56+
std::fs::remove_file(store.root().join("generation.toml")).unwrap();
57+
58+
assert_eq!(store.current_snapshot().unwrap().unwrap().id, second.id);
59+
assert!(store.doctor().unwrap().healthy);
60+
assert!(!manifest_raw(&store, &first.id).contains("generation_hmac_sha256"));
61+
62+
let third = store
63+
.snapshot_config(&Config::default(), Some("third"))
64+
.unwrap();
65+
66+
assert_eq!(third.metadata.generation, 3);
67+
assert!(store.root().join("generation.toml").is_file());
68+
assert!(store.doctor().unwrap().healthy);
69+
for id in [&first.id, &second.id, &third.id] {
70+
assert!(manifest_raw(&store, id).contains("generation_hmac_sha256"));
71+
assert_eq!(
72+
store.verify(id).unwrap(),
73+
crate::SnapshotIntegrityStatus::Authenticated
74+
);
75+
}
76+
}
77+
78+
#[test]
79+
fn authenticated_counter_allows_interrupted_legacy_migration_to_resume() {
80+
let dir = TestDir::new("snapshot-legacy-migration-resume");
81+
let store = authenticated_store(&dir);
82+
let first = store
83+
.snapshot_config(&Config::default(), Some("first"))
84+
.unwrap();
85+
let second = store
86+
.snapshot_config(&Config::default(), Some("second"))
87+
.unwrap();
88+
rewrite_manifest_as_v1(&store, &first.id);
89+
90+
let third = store
91+
.snapshot_config(&Config::default(), Some("third"))
92+
.unwrap();
93+
94+
assert_eq!(third.metadata.generation, 3);
95+
assert!(store.doctor().unwrap().healthy);
96+
for id in [&first.id, &second.id, &third.id] {
97+
assert!(manifest_raw(&store, id).contains("generation_hmac_sha256"));
98+
}
99+
}
100+
101+
fn rewrite_manifest_as_v1(store: &SnapshotStore, id: &str) {
102+
let path = integrity_path(store, id);
103+
let mut manifest: toml::Table =
104+
toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
105+
manifest.remove("generation");
106+
manifest.remove("generation_hmac_sha256");
107+
std::fs::write(path, toml::to_string_pretty(&manifest).unwrap()).unwrap();
108+
}
109+
110+
fn manifest_raw(store: &SnapshotStore, id: &str) -> String {
111+
std::fs::read_to_string(integrity_path(store, id)).unwrap()
112+
}
113+
114+
fn integrity_path(store: &SnapshotStore, id: &str) -> PathBuf {
115+
store
116+
.root()
117+
.join("configs")
118+
.join(format!("{id}.integrity.toml"))
119+
}
120+
121+
fn authenticated_store(dir: &TestDir) -> SnapshotStore {
122+
let key = dir.child("snapshot.key");
123+
std::fs::write(&key, [17_u8; 32]).unwrap();
124+
set_private_file(&key);
125+
SnapshotStore::with_integrity_key_file(dir.child("store"), &key, Arc::new(TestCryptoProvider))
126+
.unwrap()
127+
}
128+
129+
fn set_private_file(path: &Path) {
130+
#[cfg(unix)]
131+
{
132+
use std::os::unix::fs::PermissionsExt as _;
133+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap();
134+
}
135+
#[cfg(not(unix))]
136+
let _ = path;
137+
}
138+
139+
struct TestDir(PathBuf);
140+
141+
impl TestDir {
142+
fn new(name: &str) -> Self {
143+
let path = unique_temp_path(name);
144+
std::fs::create_dir(&path).unwrap();
145+
Self(path)
146+
}
147+
148+
fn child(&self, name: &str) -> PathBuf {
149+
safe_child_path(&self.0, name)
150+
}
151+
}
152+
153+
impl Drop for TestDir {
154+
fn drop(&mut self) {
155+
let _ = std::fs::remove_dir_all(&self.0);
156+
}
157+
}

0 commit comments

Comments
 (0)