diff --git a/CHANGELOG.md b/CHANGELOG.md index 569a9f19c..073c6cf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 3, slice 3C part 3c-1 — key epochs checked against the root (#445):** the + protected-storage core now stores its key-epoch records and, both when committing a new root of + trust and when starting up, requires that the root names exactly those records and that its + active key epoch is the one bound to the key it is protected with. PR #945. - **R-15 Gate 3, slice 3C part 3b — committing the root of trust (#445):** the protected-storage core can now commit a new root of trust in the order the storage contract requires, finish or cleanly abandon a commit that was interrupted at any step, and on startup trust only the root the diff --git a/crates/worldscript-secure-storage/src/commit.rs b/crates/worldscript-secure-storage/src/commit.rs index b16e9a1f6..72160388d 100644 --- a/crates/worldscript-secure-storage/src/commit.rs +++ b/crates/worldscript-secure-storage/src/commit.rs @@ -452,7 +452,7 @@ fn load_chain(fs: &mut F, store: RecordStore<'_>) -> Result Option { +pub(crate) fn first_gap(generations: &[u64]) -> Option { generations .iter() .zip(1u64..) @@ -860,7 +860,7 @@ fn is_rejected_name(name: &OsString) -> bool { } /// `generation-.wsr1` with canonical decimal `n >= 1`. -fn parse_generation_name(name: &OsString) -> Option { +pub(crate) fn parse_generation_name(name: &OsString) -> Option { let digits = name .to_str()? .strip_prefix("generation-")? @@ -869,7 +869,7 @@ fn parse_generation_name(name: &OsString) -> Option { } /// `generation-.wsr1.tmp--` with a canonical operation ID and matching `n`. -fn parse_staging_name(name: &str) -> Option<(u64, WriteOperationId)> { +pub(crate) fn parse_staging_name(name: &str) -> Option<(u64, WriteOperationId)> { let rest = name.strip_prefix("generation-")?; let (digits, rest) = rest.split_once(".wsr1.tmp-")?; let generation = parse_counter(digits)?; @@ -878,7 +878,7 @@ fn parse_staging_name(name: &str) -> Option<(u64, WriteOperationId)> { (tail == digits).then_some((generation, operation)) } -fn parse_counter(digits: &str) -> Option { +pub(crate) fn parse_counter(digits: &str) -> Option { let canonical = !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && !digits.starts_with('0'); diff --git a/crates/worldscript-secure-storage/src/durable.rs b/crates/worldscript-secure-storage/src/durable.rs index 0f11fcb47..72ea45c9f 100644 --- a/crates/worldscript-secure-storage/src/durable.rs +++ b/crates/worldscript-secure-storage/src/durable.rs @@ -57,6 +57,8 @@ pub trait DurableFs { /// atomic-rename-and-fsync mechanism). Used only for the recoverable root pointer, never for a /// generation file. fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()>; + /// Creates `dir` and its missing parents; an existing directory is not an error. + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()>; } /// The real filesystem. On Apple platforms `File::sync_all` issues `F_FULLFSYNC`; on Windows it is @@ -111,6 +113,10 @@ impl DurableFs for StdFs { fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { fs::rename(from, to) } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + fs::create_dir_all(dir) + } } /// A Core-generated write operation identity: 128 random bits as 32 lowercase hex characters, so it diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index d07e3f54f..2ae9d8d29 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -81,8 +81,9 @@ pub use root_record::{ KeyEpochWrite, RootPointer, RootRecordError, RootSlotRead, }; pub use root_store::{ - commit_root, load_committed_root, recover_root, CommittedRootView, RootCommitRequest, - RootCommitted, RootLayout, RootRecovery, RootRecoveryReason, RootStep, RootStoreError, + commit_root, load_committed_root, load_key_epoch_set, recover_root, write_key_epoch, + CommittedRootView, KeyEpochCommit, RootCommitRequest, RootCommitted, RootLayout, RootRecovery, + RootRecoveryReason, RootStep, RootStoreError, }; #[cfg(feature = "test-randomness")] pub use seal::seal_with_random; diff --git a/crates/worldscript-secure-storage/src/root_record.rs b/crates/worldscript-secure-storage/src/root_record.rs index 71f8e7dc4..c0217f950 100644 --- a/crates/worldscript-secure-storage/src/root_record.rs +++ b/crates/worldscript-secure-storage/src/root_record.rs @@ -313,6 +313,27 @@ pub struct KeyEpochRead<'a> { pub envelope: &'a [u8], } +/// The identity, envelope metadata and plaintext of `record` at `write`, for callers that seal +/// through the durable staging path (slice 3A) instead of [`KeyEpochRecord::seal`]. +pub(crate) fn key_epoch_plaintext( + record: &KeyEpochRecord, + write: &KeyEpochWrite<'_>, +) -> Result<(RecordIdentity, RecordMeta, Vec), RootRecordError> { + let address = write.address; + let identity = address.identity()?; + if address.epoch != record.epoch { + return Err(RootRecordError::Corrupt( + "key-epoch record names another epoch", + )); + } + let meta = RecordMeta { + key_epoch: write.key_epoch, + record_generation: address.registry_generation, + record_schema: CONTROL_RECORD_SCHEMA, + }; + Ok((identity, meta, record.encode()?)) +} + /// Where one key-epoch record generation lives: `key-epoch::` at /// `registry_generation`. Both counters are checked before anything uses them. #[derive(Debug, Clone, Copy)] diff --git a/crates/worldscript-secure-storage/src/root_store.rs b/crates/worldscript-secure-storage/src/root_store.rs index 0aad98a36..fd72b35e4 100644 --- a/crates/worldscript-secure-storage/src/root_store.rs +++ b/crates/worldscript-secure-storage/src/root_store.rs @@ -19,13 +19,16 @@ //! //! Physical layout (a locator, never identity or AAD): `/slot-a/generation-.wsr1`, //! `/slot-b/generation-.wsr1`, and `/pointer`; the platform adapter creates -//! the directories. Exclusive write admission and -//! `root_commit_mutex` (§11.1) are Gate 4; this module assumes one writer. Verifying the root's -//! key-epoch set (cold-start step 5) needs persisted key-epoch records and is slice 3C part 3c. +//! the slot directories; key-epoch records live in `/key-epoch//generation-.wsr1`. +//! Both a commit and the cold start verify the root's key-epoch set (§5.3.1 step 5): it must hash to +//! `key_epoch_set_digest`, with `active_key_epoch` exactly one `KEY_EPOCH_ACTIVE` record that binds +//! the root's key route. Exclusive write admission and `root_commit_mutex` (§11.1) are Gate 4; this +//! module assumes one writer. use std::io::{self, Write}; use std::path::{Path, PathBuf}; +use crate::commit::{first_gap, parse_counter, parse_generation_name, parse_staging_name}; use crate::commit::{relocate, CommitError}; use crate::durable::{ generation_path, stage_and_promote, DirectoryDurability, DurableFs, StageFailure, StageRequest, @@ -36,9 +39,13 @@ use crate::provider::{ AnchorState, InstallationScopeId, KeyProvider, PrepareRootAnchor, PreparedRootCommit, RootKeyRefV1, RootSlot, }; -use crate::root::{root_digest, RootBody, RootCommitState, RootError}; +use crate::root::{ + key_epoch_set_digest, root_digest, KeyEpochEntry, RootBody, RootCommitState, RootError, +}; use crate::root_record::{ - open_root_slot, root_identity, root_slot_plaintext, RootPointer, RootRecordError, RootSlotRead, + key_epoch_plaintext, open_root_slot, root_identity, root_slot_plaintext, KeyEpochAddress, + KeyEpochRead, KeyEpochRecord, KeyEpochStatus, KeyEpochWrite, RootPointer, RootRecordError, + RootSlotRead, }; /// Where the root slots and the pointer live. @@ -62,6 +69,14 @@ impl RootLayout<'_> { fn pointer_file(&self) -> PathBuf { self.root_dir.join("pointer") } + + fn key_epochs_dir(&self) -> PathBuf { + self.root_dir.join("key-epoch") + } + + fn key_epoch_dir(&self, epoch: u64) -> PathBuf { + self.key_epochs_dir().join(epoch.to_string()) + } } /// The durability step at which a root operation stopped. @@ -69,6 +84,8 @@ impl RootLayout<'_> { pub enum RootStep { ReadSlot, SyncSlot, + ReadKeyEpoch, + WriteKeyEpoch, WritePointer, ReadPointer, RelocateSlot, @@ -86,6 +103,12 @@ pub enum RootRecoveryReason { /// The pointer already names a prepared target that does not authenticate: the filesystem /// moved to a root the secure anchor cannot prove it authorized (§5.3.1, after E2 before F). PointerNamesUnprovenTarget, + /// The key-epoch records do not hash to the root's `key_epoch_set_digest`, a record chain has a + /// gap or does not open, or an unexpected entry is present (§5.4, cold-start step 5). + KeyEpochSetMismatch, + /// The root's `active_key_epoch` is not exactly one `KEY_EPOCH_ACTIVE` record binding the root's + /// key route (§5.3.1 step 5, §8.3). + ActiveEpochNotBound, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -173,6 +196,10 @@ pub fn commit_root( return Err(RootStoreError::ScopeMismatch); } let prepare = preparation(&anchor, request)?; + // The root may name only the key-epoch set that is durably on disk, with its active epoch bound + // to the route the root is committed under (§5.3.1 step 5, checked before any durable write). + let key_epochs = load_key_epoch_set(fs, provider, layout, request.scope, request.root_key_ref)?; + verify_key_epochs(request.root, &key_epochs)?; let operation = WriteOperationId::generate().map_err(RootStoreError::OperationId)?; provider .prepare_root_anchor(&prepare) @@ -307,6 +334,8 @@ pub fn load_committed_root( if root.root_key_ref_digest != committed.root_key_ref.digest() { return Err(recovery(RootRecoveryReason::KeyRouteMismatch)); } + let key_epochs = load_key_epoch_set(fs, provider, layout, &scope, &committed.root_key_ref)?; + verify_key_epochs(&root, &key_epochs)?; let pointer = RootPointer { slot: committed.root_slot, root_generation: committed.root_generation, @@ -321,6 +350,194 @@ pub fn load_committed_root( })) } +/// A key-epoch record generation to persist: the record, its `registry_generation`, and the root +/// key route it is sealed under (key-epoch records are control records of the root, §5.3). +#[derive(Debug, Clone, Copy)] +pub struct KeyEpochCommit<'a> { + pub scope: &'a InstallationScopeId, + pub record: &'a KeyEpochRecord, + pub registry_generation: u64, + pub root_key_ref: &'a RootKeyRefV1, + /// The data epoch whose key seals the record. + pub key_epoch: u64, +} + +/// Persists a key-epoch record generation as `/key-epoch//generation-.wsr1` +/// (immutable, generation-addressed; `n` must be exactly the next generation of that epoch) and +/// returns its `key_epoch_set_digest` entry. A root naming it is committed separately. +pub fn write_key_epoch( + fs: &mut F, + provider: &P, + layout: RootLayout<'_>, + commit: KeyEpochCommit<'_>, +) -> Result { + let dir = layout.key_epoch_dir(commit.record.epoch); + // Everything is validated before any directory is created, so a refused write leaves nothing. + let existing = epoch_generations(fs, &dir)?; + if commit.registry_generation != existing.len() as u64 + 1 { + return Err(RootStoreError::GenerationNotNext); + } + let write = KeyEpochWrite { + address: KeyEpochAddress { + scope: commit.scope, + epoch: commit.record.epoch, + registry_generation: commit.registry_generation, + }, + key_epoch: commit.key_epoch, + }; + let (identity, meta, payload) = + key_epoch_plaintext(commit.record, &write).map_err(RootStoreError::Record)?; + let key = provider + .resolve_ref(commit.root_key_ref) + .map_err(RootStoreError::Anchor)?; + ensure_durable_dir( + fs, + &dir, + &[layout.key_epochs_dir().as_path(), layout.root_dir], + )?; + let operation = WriteOperationId::generate().map_err(RootStoreError::OperationId)?; + let stage = StageRequest { + dir: &dir, + identity: &identity, + meta, + operation: &operation, + retain_staging: false, + }; + let promoted = + stage_and_promote(fs, &key, &stage, &payload).map_err(RootStoreError::SlotWrite)?; + Ok(KeyEpochEntry { + epoch: commit.record.epoch, + registry_generation: commit.registry_generation, + content_digest: promoted.content_digest, + }) +} + +/// The current key-epoch set: for every epoch directory, the newest generation of a gap-free +/// chain, opened under the root key route. Any unexpected name, gap or unopenable record is +/// `RECOVERY_REQUIRED` — never a silently shorter set. +pub fn load_key_epoch_set( + fs: &mut F, + provider: &P, + layout: RootLayout<'_>, + scope: &InstallationScopeId, + root_key_ref: &RootKeyRefV1, +) -> Result, RootStoreError> { + let names = match fs.list_dir(&layout.key_epochs_dir()) { + Ok(names) => names, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(io_error(RootStep::ReadKeyEpoch, &error)), + }; + let key = provider + .resolve_ref(root_key_ref) + .map_err(RootStoreError::Anchor)?; + let mut set = Vec::with_capacity(names.len()); + for name in names { + let epoch = name + .to_str() + .and_then(parse_counter) + .ok_or(recovery(RootRecoveryReason::KeyEpochSetMismatch))?; + let dir = layout.key_epoch_dir(epoch); + // An empty epoch directory (a crash after creating it) holds no record; skipping it cannot + // hide one, because the root's set digest binds every record the set must contain. + let Some(&generation) = epoch_generations(fs, &dir)?.last() else { + continue; + }; + let envelope = fs + .read(&generation_path(&dir, generation)) + .map_err(|error| io_error(RootStep::ReadKeyEpoch, &error))?; + let read = KeyEpochRead { + address: KeyEpochAddress { + scope, + epoch, + registry_generation: generation, + }, + envelope: &envelope, + }; + set.push( + KeyEpochRecord::open(&key, &read) + .map_err(|_| recovery(RootRecoveryReason::KeyEpochSetMismatch))?, + ); + } + Ok(set) +} + +/// Creates `dir` and syncs it and every listed parent (innermost first), so a record promoted +/// into it can never be lost with its directory entry after a crash. +fn ensure_durable_dir( + fs: &mut F, + dir: &Path, + parents: &[&Path], +) -> Result<(), RootStoreError> { + let fail = |error: io::Error| io_error(RootStep::WriteKeyEpoch, &error); + fs.create_dir_all(dir).map_err(fail)?; + for path in std::iter::once(dir).chain(parents.iter().copied()) { + fs.sync_dir(path).map_err(fail)?; + } + Ok(()) +} + +/// The sorted, gap-free generations in one epoch directory. Staging leftovers of a crashed write +/// (`generation-.wsr1.tmp-…`) and relocated bytes are ignored — the root's set digest binds +/// what counts — and any other name is `RECOVERY_REQUIRED`. +fn epoch_generations(fs: &mut F, dir: &Path) -> Result, RootStoreError> { + let names = match fs.list_dir(dir) { + Ok(names) => names, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(io_error(RootStep::ReadKeyEpoch, &error)), + }; + let mut generations = Vec::with_capacity(names.len()); + for name in &names { + if let Some(generation) = parse_generation_name(name) { + generations.push(generation); + } else if !is_epoch_debris(name) { + return Err(recovery(RootRecoveryReason::KeyEpochSetMismatch)); + } + } + generations.sort_unstable(); + if first_gap(&generations).is_some() { + return Err(recovery(RootRecoveryReason::KeyEpochSetMismatch)); + } + Ok(generations) +} + +/// A staging leftover or relocated bytes in an epoch directory. +fn is_epoch_debris(name: &std::ffi::OsString) -> bool { + name.to_str().is_some_and(|name| { + parse_staging_name(name).is_some() + || (name.starts_with("generation-") && name.contains(".rejected-")) + }) +} + +/// Cold-start step 5 (§5.3.1, §8.3): the set must hash to the root's `key_epoch_set_digest`, and +/// `active_key_epoch` must be exactly one `KEY_EPOCH_ACTIVE` record binding the root's key route. +fn verify_key_epochs( + root: &RootBody, + set: &[(KeyEpochRecord, KeyEpochEntry)], +) -> Result<(), RootStoreError> { + let entries: Vec = set.iter().map(|(_, entry)| *entry).collect(); + let digest = key_epoch_set_digest(&entries) + .map_err(|_| recovery(RootRecoveryReason::KeyEpochSetMismatch))?; + if digest != root.key_epoch_set_digest { + return Err(recovery(RootRecoveryReason::KeyEpochSetMismatch)); + } + // §8.3: exactly one KEY_EPOCH_ACTIVE record exists, at active_key_epoch, binding the route. + let mut active = set + .iter() + .filter(|(record, _)| record.status == KeyEpochStatus::Active); + let bound = match (active.next(), active.next()) { + (Some((record, _)), None) => { + record.epoch == root.active_key_epoch + && record.root_key_ref.digest() == root.root_key_ref_digest + } + _ => false, + }; + if bound { + Ok(()) + } else { + Err(recovery(RootRecoveryReason::ActiveEpochNotBound)) + } +} + fn read_anchor(provider: &P) -> Result { provider .read_root_anchor_state() diff --git a/crates/worldscript-secure-storage/tests/gate3_durable_test.rs b/crates/worldscript-secure-storage/tests/gate3_durable_test.rs index bd1f85f7b..31fc777b7 100644 --- a/crates/worldscript-secure-storage/tests/gate3_durable_test.rs +++ b/crates/worldscript-secure-storage/tests/gate3_durable_test.rs @@ -269,6 +269,10 @@ impl DurableFs for FaultFs { fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { StdFs.rename_replace(from, to) } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + StdFs.create_dir_all(dir) + } } #[test] diff --git a/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs b/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs index 9b4d0b5a8..46fd791d6 100644 --- a/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs +++ b/crates/worldscript-secure-storage/tests/gate3c_root_commit_test.rs @@ -17,6 +17,10 @@ use worldscript_secure_storage::{ RootCommitRequest, RootCommitState, RootKeyRefV1, RootLayout, RootPointer, RootRecovery, RootRecoveryReason, RootSlot, RootStoreError, StdFs, }; +use worldscript_secure_storage::{ + key_epoch_set_digest, write_key_epoch, KeyEpochCommit, KeyEpochEntry, KeyEpochRecord, + KeyEpochStatus, +}; /// The real filesystem with one injected failure: creating any file directly inside a directory, /// or the atomic pointer rename. @@ -67,6 +71,10 @@ impl DurableFs for RootFault { } StdFs.rename_replace(from, to) } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + StdFs.create_dir_all(dir) + } } /// A fresh temporary root directory, cleared first and removed by `Drop for Fixture`. @@ -87,6 +95,7 @@ struct Fixture { provider: MemoryKeyProvider, scope: InstallationScopeId, key_ref: RootKeyRefV1, + key_epoch_set_digest: [u8; 32], } impl Fixture { @@ -99,12 +108,46 @@ impl Fixture { let scope = provider.read_or_provision_installation_scope().unwrap(); let key_ref = provider.provision_epoch_key(1).unwrap(); provider.unlock().unwrap(); - Fixture { + let mut fixture = Fixture { root_dir, provider, scope, key_ref, - } + key_epoch_set_digest: [0; 32], + }; + let entry = fixture.write_epoch(1, KeyEpochStatus::Active, 1).unwrap(); + fixture.key_epoch_set_digest = key_epoch_set_digest(&[entry]).unwrap(); + fixture + } + + /// Persists generation `registry_generation` of epoch 1's key-epoch record with `status`. + fn write_epoch( + &mut self, + epoch: u64, + status: KeyEpochStatus, + registry_generation: u64, + ) -> Result { + let record = KeyEpochRecord { + epoch, + status, + root_key_ref: self.key_ref.clone(), + }; + let commit = KeyEpochCommit { + scope: &self.scope, + record: &record, + registry_generation, + root_key_ref: &self.key_ref, + key_epoch: 1, + }; + let root_dir = self.root_dir.clone(); + write_key_epoch( + &mut StdFs, + &self.provider, + RootLayout { + root_dir: &root_dir, + }, + commit, + ) } fn layout(&self) -> RootLayout<'_> { @@ -121,7 +164,7 @@ impl Fixture { root_key_ref_digest: self.key_ref.digest(), marker_set_digest: [generation as u8; 32], catalog_set_digest: [0x11; 32], - key_epoch_set_digest: [0x22; 32], + key_epoch_set_digest: self.key_epoch_set_digest, commit_evidence: RootCommitEvidence { operation_id: format!("root-op-{generation}"), fencing_generation: 0, @@ -498,3 +541,146 @@ fn a_discarded_preparation_repairs_the_pointer_to_the_committed_root() { Some(1) ); } + +fn key_epoch_refusal(reason: RootRecoveryReason) -> Result<(), RootStoreError> { + Err(RootStoreError::RecoveryRequired(reason)) +} + +#[test] +fn a_root_must_name_the_persisted_key_epoch_set() { + let mut fixture = Fixture::new(); + fixture.key_epoch_set_digest = [0x22; 32]; + assert_eq!( + fixture.commit(&mut StdFs, 1), + key_epoch_refusal(RootRecoveryReason::KeyEpochSetMismatch) + ); + let anchor = fixture.provider.read_root_anchor_state().unwrap(); + assert!( + anchor.prepared_root_commit.is_none(), + "refused before step C" + ); +} + +#[test] +fn the_active_epoch_must_be_an_active_record_binding_the_root_route() { + // Generation 2 of epoch 1 moves it to PREPARED: no ACTIVE record backs active_key_epoch. + let mut fixture = Fixture::new(); + let entry = fixture.write_epoch(1, KeyEpochStatus::Prepared, 2).unwrap(); + fixture.key_epoch_set_digest = key_epoch_set_digest(&[entry]).unwrap(); + assert_eq!( + fixture.commit(&mut StdFs, 1), + key_epoch_refusal(RootRecoveryReason::ActiveEpochNotBound) + ); +} + +#[test] +fn cold_start_fails_closed_on_a_tampered_or_gapped_key_epoch_chain() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + let record = fixture + .root_dir + .join("key-epoch") + .join("1") + .join("generation-1.wsr1"); + let original = fs::read(&record).unwrap(); + flip_last_byte(&record); + let tampered = fixture.loaded_generation(); + fs::write(&record, &original).unwrap(); + // A later generation without the first one is a gap, never a shorter chain. + fs::rename(&record, record.with_file_name("generation-2.wsr1")).unwrap(); + let gapped = fixture.loaded_generation(); + let mismatch = Err(RootStoreError::RecoveryRequired( + RootRecoveryReason::KeyEpochSetMismatch, + )); + assert_eq!((tampered, gapped), (mismatch.clone(), mismatch)); +} + +#[test] +fn key_epoch_generations_are_written_strictly_in_order() { + let mut fixture = Fixture::new(); + assert_eq!( + fixture + .write_epoch(1, KeyEpochStatus::Active, 3) + .map(|_| ()), + Err(RootStoreError::GenerationNotNext) + ); +} + +#[test] +fn an_active_epoch_bound_to_another_route_is_refused() { + let mut fixture = Fixture::new(); + let other_route = fixture.provider.provision_epoch_key(2).unwrap(); + let record = KeyEpochRecord { + epoch: 1, + status: KeyEpochStatus::Active, + root_key_ref: other_route, + }; + let commit = KeyEpochCommit { + scope: &fixture.scope, + record: &record, + registry_generation: 2, + root_key_ref: &fixture.key_ref, + key_epoch: 1, + }; + let root_dir = fixture.root_dir.clone(); + let entry = write_key_epoch( + &mut StdFs, + &fixture.provider, + RootLayout { + root_dir: &root_dir, + }, + commit, + ) + .unwrap(); + fixture.key_epoch_set_digest = key_epoch_set_digest(&[entry]).unwrap(); + assert_eq!( + fixture.commit(&mut StdFs, 1), + key_epoch_refusal(RootRecoveryReason::ActiveEpochNotBound) + ); +} + +#[test] +fn a_second_active_epoch_is_refused() { + // §8.3: exactly one KEY_EPOCH_ACTIVE record may exist, whatever its epoch. + let mut fixture = Fixture::new(); + let second = fixture.write_epoch(2, KeyEpochStatus::Active, 1).unwrap(); + let entries = worldscript_secure_storage::load_key_epoch_set( + &mut StdFs, + &fixture.provider, + fixture.layout(), + &fixture.scope, + &fixture.key_ref, + ) + .unwrap(); + let set: Vec = entries.iter().map(|(_, entry)| *entry).collect(); + assert!(set.contains(&second)); + fixture.key_epoch_set_digest = key_epoch_set_digest(&set).unwrap(); + assert_eq!( + fixture.commit(&mut StdFs, 1), + key_epoch_refusal(RootRecoveryReason::ActiveEpochNotBound) + ); +} + +#[test] +fn crash_leftovers_in_key_epoch_directories_never_poison_the_set() { + let mut fixture = Fixture::new(); + fixture.commit(&mut StdFs, 1).unwrap(); + let epochs = fixture.root_dir.join("key-epoch"); + // A staging leftover beside the record, and an empty epoch directory from a crash after mkdir. + let leftover = format!("generation-2.wsr1.tmp-{}-2", "ab".repeat(16)); + fs::write(epochs.join("1").join(leftover), b"partial").unwrap(); + fs::create_dir_all(epochs.join("7")).unwrap(); + assert_eq!(fixture.loaded_generation(), Ok(Some(1))); +} + +#[test] +fn a_refused_key_epoch_write_leaves_no_directory() { + let mut fixture = Fixture::new(); + assert_eq!( + fixture + .write_epoch(5, KeyEpochStatus::Active, 2) + .map(|_| ()), + Err(RootStoreError::GenerationNotNext) + ); + assert!(!fixture.root_dir.join("key-epoch").join("5").exists()); +} diff --git a/crates/worldscript-secure-storage/tests/support/mod.rs b/crates/worldscript-secure-storage/tests/support/mod.rs index cdc5def80..c0e80a13b 100644 --- a/crates/worldscript-secure-storage/tests/support/mod.rs +++ b/crates/worldscript-secure-storage/tests/support/mod.rs @@ -233,6 +233,10 @@ impl DurableFs for FaultFs { fn rename_replace(&mut self, from: &Path, to: &Path) -> io::Result<()> { StdFs.rename_replace(from, to) } + + fn create_dir_all(&mut self, dir: &Path) -> io::Result<()> { + StdFs.create_dir_all(dir) + } } /// Marker-directory syncs in one write: the chain loads of the initial reconciliation and of the diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index 7b950732f..7e668457c 100644 --- a/docs/native/CORE-MIGRATION-LEDGER.md +++ b/docs/native/CORE-MIGRATION-LEDGER.md @@ -17,7 +17,7 @@ scope shifts — it is a living decision record, not a one-time snapshot. | 7 | `features/project/` domain logic | TS, `features/project/` (24 files, 2,114 lines) — real logic concentrated in `thunks/` + `projectSelectors.ts` (~450-500 lines); `reducers/` (11 files) is CRUD bookkeeping | High — Redux-store-shape/dispatch bound; `reducers/` stays TS-side permanently | Low | Medium (import/restore orchestration) | Low-medium | Medium (only the thunks/selectors subset) | Deferred | Candidate after the schema crate is proven; only thunks/selectors, never `reducers/` | Not started | | 8 | AI services | TS, `services/ai/` (44 files, 5,401 lines), mixed portability (retry/routing/error-taxonomy renderer-neutral vs. `computeShaderFactory.ts`/`webGpuDetectorService.ts`/`.wgsl` inherently WebGPU-coupled) | Mixed | Medium-high (API keys) | Low-medium | Medium | Uncertain — too large/mixed to assess narrowly | **Out of scope for all of Wave 2** | None proposed | None | | 9 | Project state-shape compatibility adapter | TS, `features/project/coreBoundaryAdapter.ts` at the Core boundary + Rust, `crates/worldscript-project` schema | High at the boundary — production Redux `EntityState` must be translated without importing Redux into Core | Low | High — ID/order preservation is part of project identity | Medium | High — every native renderer needs the same conversion contract | **2 — Wave 2 prerequisite before G1 evaluation** | **Current-production #553 closure complete; Rust Core authority switch not started (#836).** `IMPLEMENTATION_STARTED = YES`. Every current-production Project path is canonical and no-loss according to backend semantics (#553, PRs #773–#849): textual raw carrier and lexical tokens on the filesystem, structured-value semantics in IndexedDB. Persistence and admission: shared TS/Rust classification including the raw-token grammar; `LEGACY_TO_V1` admitted in memory and migrated durably on both backends (IndexedDB authority; filesystem under the project lock with a pre-migration snapshot, #849); the generation-fenced canonical IDB authority for web/PWA autosave, flush and manual save; the desktop filesystem writer as a preserve-first raw-carrier writeback under the project lock with a generation/incarnation fence. Egress: export and library backup (projects and snapshots), stripped to portable form. Snapshots: creation and restore, each admitted, with an exact restore carrier. Import: every modeled field admitted and projected, then an admitted-raw first save (#842, #848); a `null` tension score is admitted so the project remains loadable, omitted from the typed editor projection, and preserved in the canonical raw carrier. Export: every JSON surface, including Advanced import/export, through the canonical egress (#847). Replacement and authority: same-ID replacement writes a fresh canonical document, with carriers bound to target, epoch and authority; the desktop fails closed when filesystem storage is unavailable, with no IndexedDB fallback; an unloadable browser record is refused and kept. The closure guarantees no silent loss or replacement of stored data; it does not promise that every malformed shape boots into the editor (malformed manuscript-section hardening is #845). TypeScript remains the production Project authority; the renderer-neutral Rust Core authority switch is separate future work (#836). Normalizes array or Redux `EntityState` to renderer-neutral arrays and reconstructs the TS-side shape only at the integration boundary. The Rust verdict remains partial because unknown fields are not rejected (Rust is observation-only until #836); within the current TypeScript authority, every current-production ingress, writer, migration and egress preserves the authoritative canonical carrier according to backend semantics — the textual raw carrier and its lexical tokens where textual authority exists (filesystem), the stored structured value in IndexedDB (canonically serialized where text is needed, with no claim that original JSON text survives). Both required decisions (persisted version authority; a field-class-staged unknown-field policy, not one global policy) are resolved and maintainer-admitted in [`docs/native/PROJECT-CORE-COMPATIBILITY-CONTRACT.md`](PROJECT-CORE-COMPATIBILITY-CONTRACT.md), `PROPOSED = YES` / `ADMITTED = YES`. Issue #553's current-production implementation of that contract is complete; its terminal acceptance is QNB-99 (pending); the authority switch it gates is #836. | `tests/unit/features/project/coreBoundaryAdapter.test.ts` covers array and `EntityState` inputs, round-trip ID/order preservation, and rejection of duplicate IDs, missing references, and orphaned entities for both characters and worlds; `tests/unit/features/project/projectSchemaVersion.test.ts` and `crates/worldscript-project/tests/version_test.rs` cover classification/parity; the IDB load observation is covered by `tests/unit/services/storage/idbProjectStoreLoadStateObservation.test.ts`; the canonical parser/import/admission foundation is covered by `tests/unit/projectDocument.test.ts` and `tests/unit/projectImportSchema.test.ts`; the writeback overlay/verify/fence primitive by `tests/unit/services/projectDocumentWriteback.test.ts`; the IDB canonical admission/durable-commit boundary (against real fake-indexeddb, including a generation-conflict rejection, a §2.7 downgrade-contradiction, and an encrypted round trip) by `tests/unit/services/storage/idbProjectCanonicalAuthority.test.ts`; production routing/refusal-success coverage by `tests/unit/services/projectAutosavePersistence.test.ts`, `tests/unit/persistedStateFlush.test.ts`, and the listener/shortcut tests; the envelope fixture is accepted by Rust after migration and validation; the #553 current-production closure (a1–a11) by `tests/unit/services/projectCanonicalEgress.test.ts`, `tests/unit/libraryBackupService.test.ts`, `tests/unit/services/fs/fsStores.test.ts`, `tests/unit/services/projectAutosaveCanonicalWriter.test.ts`, `tests/unit/services/projectImportCarrier.test.ts`, `tests/unit/storageServiceDesktopAuthority.test.ts` and `tests/unit/malformedProjectBoot.test.ts` | -| 10 | R-15 protected desktop storage contract | **Contract `docs/native/R15-SECURE-STORAGE-CONTRACT.md` + headless Rust implementation (Gates 1a/1b/2 and Gate 3 slices 3A, 3B and 3C parts 1–3b) in `crates/worldscript-secure-storage`, not production authority**; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL=YES (PR #584 merged `c24aa645`, post-merge CI/CD + CodeQL green); Gate 1a re-admitted by QNB-100 (2026-09-26) and implemented headless in `crates/worldscript-secure-storage`; Gate 1b decided 2026-09-26 (Option C: platform secure store primary, optional `WSS_ARGON2ID_V1` passphrase recovery); 1b-core landed (#850); 1b-platform delivered as small sequential slices — §8.2.2 item layout (#854), durable authority (#855), runtime key handles (#914), anchor transitions + `KeyProvider` (#915), OS secure-store adapter (§8.2.5, #916; evidence Linux `CI_ONLY`/`LOCAL_ONLY`, macOS/Windows `CI_ONLY`, no packaged evidence); Gate 2 (typed identity registry, identity-bound record codec and §10.4.1 record-class disposition) admitted and implemented headless (§20, #920); legacy source-locator mapping belongs to Gate 5; #361's shipped-helper gap closes only with Gate 7; Gate 3 slice 3A (durable staging and promotion, §9 steps 3–8) and slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) and slice 3C parts 1–3b (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, the root slot, pointer and key-epoch record encodings, §5.3.4, and the two-phase root commit with crash recovery and trusted cold start, §5.3.1) implemented headless, with the write-protocol integration, key-epoch verification, `list_records` and retention (rest of 3C) remaining (#921); the rest of Gate 3 and Gates 4–7 (including Gate 4 cross-process serialization) not admitted** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=YES / R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3C_ROOT_COMMIT / PRODUCTION_AUTHORITY_SWITCH_ALLOWED=NO**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is YES: PR #584 merged and its post-merge main CI (incl. CodeQL) was green. Gate 1a's headless vectors (contract header fixture, fixed-key AEAD for absent/present `project_id`, rule-D boundary, malformed-input and substitution tests, cross-checked against an independent implementation) now exist; the Gate 1b platform secure-store adapter exists with CI/local per-platform evidence (#916, contract §8.2.5); packaged secure-store evidence, Gate 4 cross-process serialization, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before the later implementation gates can close | +| 10 | R-15 protected desktop storage contract | **Contract `docs/native/R15-SECURE-STORAGE-CONTRACT.md` + headless Rust implementation (Gates 1a/1b/2 and Gate 3 slices 3A, 3B and 3C parts 1–3c-1) in `crates/worldscript-secure-storage`, not production authority**; current desktop records remain TS/Tauri filesystem authority | High — future Core must serve Tauri and Qt without renderer-private crypto semantics | High | High — durability, migration, and identity binding protect user data | High | **Highest — cross-renderer security/durability contract** | **3 — S5-A, S5-B1, S5-B2, and S5-B3 all admitted; final cross-contract audit complete, S5_TERMINAL=YES (PR #584 merged `c24aa645`, post-merge CI/CD + CodeQL green); Gate 1a re-admitted by QNB-100 (2026-09-26) and implemented headless in `crates/worldscript-secure-storage`; Gate 1b decided 2026-09-26 (Option C: platform secure store primary, optional `WSS_ARGON2ID_V1` passphrase recovery); 1b-core landed (#850); 1b-platform delivered as small sequential slices — §8.2.2 item layout (#854), durable authority (#855), runtime key handles (#914), anchor transitions + `KeyProvider` (#915), OS secure-store adapter (§8.2.5, #916; evidence Linux `CI_ONLY`/`LOCAL_ONLY`, macOS/Windows `CI_ONLY`, no packaged evidence); Gate 2 (typed identity registry, identity-bound record codec and §10.4.1 record-class disposition) admitted and implemented headless (§20, #920); legacy source-locator mapping belongs to Gate 5; #361's shipped-helper gap closes only with Gate 7; Gate 3 slice 3A (durable staging and promotion, §9 steps 3–8) and slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) and slice 3C parts 1–3c-1 (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, the root slot, pointer and key-epoch record encodings, §5.3.4, and the two-phase root commit with crash recovery and the trusted cold start including the key-epoch set check, §5.3.1) implemented headless, with the write-protocol integration, `list_records` and retention (rest of 3C) remaining (#921); the rest of Gate 3 and Gates 4–7 (including Gate 4 cross-process serialization) not admitted** | **S5_A_ADMITTED=YES / S5_B1_ADMITTED=YES / S5_B2_ADMITTED=YES / S5_B3_ADMITTED=YES / S5_IMPLEMENTATION_READY=NO / S5_TERMINAL=YES / R15_GATE1A=IMPLEMENTED_HEADLESS / R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER / R15_GATE2=IMPLEMENTED_HEADLESS / R15_GATE3=SLICE_3C_KEY_EPOCHS / PRODUCTION_AUTHORITY_SWITCH_ALLOWED=NO**; inventory, identity/AAD envelope, key epochs, fail-closed reads, durable replacement, crash-resumable migration, unified admission, race-free `AuthoritySnapshot` acquisition/lifetime (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md`), canonical migration source/payload evidence (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md`), and the chunked large-object envelope (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md`) are all specified. No production authority switch or plaintext migration is claimed. | Final S5 cross-contract consistency audit (mutual reference integrity across all four documents) is complete — two mechanical citation-drift notes (a stale disposition-count note in §10.4.1, and S5-B3's mis-citation of S5-B1's migration-time mechanism for its own ordinary-write staging debris) and three substantive gaps were corrected: S5-B3's chunk-locator carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current one; §10.4.1's atomic-write-temporary-files carve-out contradicted its own "exactly one of three groups" exhaustiveness claim; and fixing that carve-out into an explicit `REFUSE_AUTHORITY_SWITCH` group in turn made Gate 7's class-level rule permanently unsatisfiable for that one class, fixed by making Gate 7 instance-aware. `S5_TERMINAL` is YES: PR #584 merged and its post-merge main CI (incl. CodeQL) was green. Gate 1a's headless vectors (contract header fixture, fixed-key AEAD for absent/present `project_id`, rule-D boundary, malformed-input and substitution tests, cross-checked against an independent implementation) now exist; the Gate 1b platform secure-store adapter exists with CI/local per-platform evidence (#916, contract §8.2.5); packaged secure-store evidence, Gate 4 cross-process serialization, per-record migration tests, packaged durability evidence, and explicit #357/#359/#360/#361 reconciliation are still required before the later implementation gates can close | ## Decisions this table records diff --git a/docs/native/R15-SECURE-STORAGE-CONTRACT.md b/docs/native/R15-SECURE-STORAGE-CONTRACT.md index fc2575d4c..26b09a1bf 100644 --- a/docs/native/R15-SECURE-STORAGE-CONTRACT.md +++ b/docs/native/R15-SECURE-STORAGE-CONTRACT.md @@ -4,7 +4,7 @@ **Status:** S5-A — admitted R-15 secure-storage architecture baseline; production implementation not started. `S5_A_ADMITTED = YES`, `S5_IMPLEMENTATION_READY = NO`, `S5_TERMINAL = YES` (PR #584 merged as `c24aa645`; its post-merge main CI — CI Success and CodeQL — completed green, 19 success / 3 skipped), -`PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. `S5_B2_ADMITTED = YES` (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md` — race-free `AuthoritySnapshot` acquisition/lifetime/reclamation, §5.3.3). `S5_B1_ADMITTED = YES` (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md` — canonical JSON encoding, packaged-IDB source evidence, per-class `canonical_destination_payload_bytes`/`source_value_digest`, atomic-write-temporary reconciliation, and identity-upgrade/recovery for unbound sources and legacy quarantine, §10.1.2, §10.1.3, §10.4.1). `S5_B3_ADMITTED = YES` (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md` — per-chunk-authenticated envelope and `chunk_set_digest` for records above the `64 MiB` whole-record limit, §6.1.2, §6.3, §13). All three S5 child contracts are admitted, and the final cross-contract consistency audit (S5-A/S5-B1/S5-B2/S5-B3 mutual reference integrity) is complete: five findings were made and corrected in this same change — two mechanical citation-drift notes (a stale "blocked pending S5-B1" disposition-count note in §10.4.1, and S5-B3's §6 crash-recovery paragraph citing S5-B1's migration-time reconciliation mechanism for an ordinary write's own orphaned staging chunk, where §9.2/§9 step 11's own ordinary-write staging-reconciliation rule actually applies) and three substantive gaps (S5-B3's chunk physical locator, §2, carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current attempt's — closed by giving each chunk's staging form the same `operation_id`/`target_generation` temp suffix §9 step 3 already defines for a whole record, and by making §6's recovery text check that exact suffix rather than the bare promoted-form locator; and §10.4.1's atomic-write-temporary-files carve-out was declared exempt from "exactly one of the three groups," directly contradicting that same exhaustiveness invariant — closed by making it an explicit fourth `REFUSE_AUTHORITY_SWITCH` group; that fix in turn made Gate 7's flat class-level rule ("blocked while any class is `REFUSE_AUTHORITY_SWITCH`") permanently unsatisfiable for this one class, since its class-level registry entry never changes even once every instance resolves — closed by making Gate 7's rule instance-aware, so only an *unresolved* `REFUSE_AUTHORITY_SWITCH` instance blocks it). No further inconsistency was found after these corrections. `S5_TERMINAL` and `S5_TERMINAL_R15_DESIGN_ADMITTED_MERGED_POSTMERGE_GREEN` are now YES, recorded in a dedicated follow-up after PR #584 merged and its post-merge main CI (including CodeQL) was confirmed green. **Gate 1a (§20) was re-admitted by the QNB-100 readiness verdict on 2026-09-26** (recorded on [#445](https://github.com/qnbs/WorldScript-Studio/issues/445#issuecomment-5847938516); no earlier Gate 1 admission is recorded). Gate 1a is the headless `WSR1` header and strict parser, the record-class registry, canonical AAD (§6.2), AES-256-GCM seal/open with a fail-closed OS nonce source, and the fixed-key/boundary/adversarial vectors §6.1 requires, in `crates/worldscript-secure-storage`. Gate 1b (the key-provider/KDF profile, §8.2/§8.2.1) was decided on 2026-09-26 as Option C; 1b-core landed in #850 and 1b-platform landed as small sequential slices (#854, #855, #914, #915, #916), ending with the OS secure-store adapter (§8.2.5). Status split: S5 design terminal/admitted = YES; Gate 1a = re-admitted and implemented headless; `S5_IMPLEMENTATION_READY = NO` for the R-15 program as a whole; Gate 1b = decided and implemented headless with the platform secure-store adapter (1b-core #850; 1b-platform #854, #855, #914, #915, #916); Gate 2 (typed identity registry, identity-bound record codec and record-class disposition, §20) = admitted and implemented headless; Gate 3 slice 3A (durable staging and promotion) = admitted and implemented headless; Gate 3 slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) = admitted and implemented headless; Gate 3 slice 3C parts 1, 2, 3a and 3b (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, the root slot, pointer and key-epoch record encodings, §5.3.4, and the two-phase root commit with crash recovery and trusted cold start, §5.3.1) = admitted and implemented headless; the rest of Gate 3 and Gates 4–7 = not admitted; `PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. No current TypeScript/Tauri path reads or writes user data through this crate. +`PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. `S5_B2_ADMITTED = YES` (`docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md` — race-free `AuthoritySnapshot` acquisition/lifetime/reclamation, §5.3.3). `S5_B1_ADMITTED = YES` (`docs/native/r15/MIGRATION-SOURCE-EVIDENCE.md` — canonical JSON encoding, packaged-IDB source evidence, per-class `canonical_destination_payload_bytes`/`source_value_digest`, atomic-write-temporary reconciliation, and identity-upgrade/recovery for unbound sources and legacy quarantine, §10.1.2, §10.1.3, §10.4.1). `S5_B3_ADMITTED = YES` (`docs/native/r15/CHUNKED-LARGE-OBJECT-ENVELOPE.md` — per-chunk-authenticated envelope and `chunk_set_digest` for records above the `64 MiB` whole-record limit, §6.1.2, §6.3, §13). All three S5 child contracts are admitted, and the final cross-contract consistency audit (S5-A/S5-B1/S5-B2/S5-B3 mutual reference integrity) is complete: five findings were made and corrected in this same change — two mechanical citation-drift notes (a stale "blocked pending S5-B1" disposition-count note in §10.4.1, and S5-B3's §6 crash-recovery paragraph citing S5-B1's migration-time reconciliation mechanism for an ordinary write's own orphaned staging chunk, where §9.2/§9 step 11's own ordinary-write staging-reconciliation rule actually applies) and three substantive gaps (S5-B3's chunk physical locator, §2, carried no operation/generation identity, so recovery could not distinguish a superseded attempt's orphaned chunk from the current attempt's — closed by giving each chunk's staging form the same `operation_id`/`target_generation` temp suffix §9 step 3 already defines for a whole record, and by making §6's recovery text check that exact suffix rather than the bare promoted-form locator; and §10.4.1's atomic-write-temporary-files carve-out was declared exempt from "exactly one of the three groups," directly contradicting that same exhaustiveness invariant — closed by making it an explicit fourth `REFUSE_AUTHORITY_SWITCH` group; that fix in turn made Gate 7's flat class-level rule ("blocked while any class is `REFUSE_AUTHORITY_SWITCH`") permanently unsatisfiable for this one class, since its class-level registry entry never changes even once every instance resolves — closed by making Gate 7's rule instance-aware, so only an *unresolved* `REFUSE_AUTHORITY_SWITCH` instance blocks it). No further inconsistency was found after these corrections. `S5_TERMINAL` and `S5_TERMINAL_R15_DESIGN_ADMITTED_MERGED_POSTMERGE_GREEN` are now YES, recorded in a dedicated follow-up after PR #584 merged and its post-merge main CI (including CodeQL) was confirmed green. **Gate 1a (§20) was re-admitted by the QNB-100 readiness verdict on 2026-09-26** (recorded on [#445](https://github.com/qnbs/WorldScript-Studio/issues/445#issuecomment-5847938516); no earlier Gate 1 admission is recorded). Gate 1a is the headless `WSR1` header and strict parser, the record-class registry, canonical AAD (§6.2), AES-256-GCM seal/open with a fail-closed OS nonce source, and the fixed-key/boundary/adversarial vectors §6.1 requires, in `crates/worldscript-secure-storage`. Gate 1b (the key-provider/KDF profile, §8.2/§8.2.1) was decided on 2026-09-26 as Option C; 1b-core landed in #850 and 1b-platform landed as small sequential slices (#854, #855, #914, #915, #916), ending with the OS secure-store adapter (§8.2.5). Status split: S5 design terminal/admitted = YES; Gate 1a = re-admitted and implemented headless; `S5_IMPLEMENTATION_READY = NO` for the R-15 program as a whole; Gate 1b = decided and implemented headless with the platform secure-store adapter (1b-core #850; 1b-platform #854, #855, #914, #915, #916); Gate 2 (typed identity registry, identity-bound record codec and record-class disposition, §20) = admitted and implemented headless; Gate 3 slice 3A (durable staging and promotion) = admitted and implemented headless; Gate 3 slice 3B (the `record-commit` marker codec, §5.4, and the marker commit protocol with startup reconciliation, §9/§9.2) = admitted and implemented headless; Gate 3 slice 3C parts 1, 2, 3a, 3b and 3c-1 (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, the root slot, pointer and key-epoch record encodings, §5.3.4, and the two-phase root commit with crash recovery and the trusted cold start including the key-epoch set check, §5.3.1) = admitted and implemented headless; the rest of Gate 3 and Gates 4–7 = not admitted; `PRODUCTION_AUTHORITY_SWITCH_ALLOWED = NO`. No current TypeScript/Tauri path reads or writes user data through this crate. **Baseline:** `main` at `7ce506ee771f6273e22c08ded049b48955cb40a5` @@ -3553,7 +3553,7 @@ R15_GATE_STATUS R15_GATE1A=IMPLEMENTED_HEADLESS R15_GATE1B=IMPLEMENTED_HEADLESS_AND_PLATFORM_ADAPTER R15_GATE2=IMPLEMENTED_HEADLESS -R15_GATE3=SLICE_3C_ROOT_COMMIT +R15_GATE3=SLICE_3C_KEY_EPOCHS R15_GATE4=NOT_ADMITTED R15_GATE5=NOT_ADMITTED R15_GATE6=NOT_ADMITTED @@ -3794,10 +3794,20 @@ Later implementation may be admitted only in these bounded gates: always wins). Tests drive every crash window with the fault-injecting in-memory provider and filesystem faults, plus a tampered target slot, a tampered or missing committed slot and a stale or missing pointer; they run on Linux, macOS and Windows CI runners (`CI_ONLY`, not - power-loss evidence). Verifying the root's key-epoch set (cold-start step 5) — which is also - where `active_key_epoch` is bound to the root's key route, since §5.3.1 forbids using - `list_epochs` as a trust input — needs persisted key-epoch records and, with the write-protocol - integration, `list_records` and retention, is the rest of slice 3C. `root_commit_mutex` and exclusive admission are Gate 4. + power-loss evidence). + - **Slice 3C, part 3c-1 (key-epoch records and the cold-start key-epoch check)** — + `write_key_epoch` persists a key-epoch record generation as + `/key-epoch//generation-.wsr1` (immutable, exactly the next generation of + that epoch, sealed under the root key route) and `load_key_epoch_set` reads the newest + generation of every epoch's gap-free chain, refusing any unexpected name, gap or record that + does not open. Both `commit_root` (before step C) and `load_committed_root` enforce §5.3.1 step 5: + the set must hash to the root's `key_epoch_set_digest`, and `active_key_epoch` must be exactly one + `KEY_EPOCH_ACTIVE` record whose key route's digest is the root's `root_key_ref_digest` — which + is how `active_key_epoch` is bound to the route without ever using `list_epochs` as a trust + input. Tests cover a root naming another set, an active epoch that is only `PREPARED`, an + active epoch bound to another route, a tampered key-epoch record, a chain gap and out-of-order + generations. The write-protocol integration (catalog and root per marker transition, dropping + a rolled-back first write), `list_records` and retention are the rest of slice 3C. `root_commit_mutex` and exclusive admission are Gate 4. 4. **Journal/admission:** implement enable/rotate/recovery state machines, exclusive migration admission, bounded inventory/checkpoints, and shutdown/cancellation behavior. 5. **Migration admission readiness and inventory-complete migration** (admission-readiness, not full @@ -3833,4 +3843,4 @@ complete merely because a design document exists. ## 21. S5 admission decision -This S5-A baseline, together with S5-B1/S5-B2/S5-B3, is admitted at the semantic level for everything each actually specifies (protected records/representations enumerated; logical identity, envelope, key/epoch, parse, failure, and downgrade semantics explicit; durable writes, generations/commit markers, admission, lock, recovery, and memory bounds defined; canonical migration-source/payload evidence, race-free `AuthoritySnapshot` lifetime, and the chunked large-object envelope all admitted above; Core-vs-platform responsibilities and headless tests explicit; #357/#359/#360/#361 have implementation owners and closure evidence) but is **not** implementation-ready: no production implementation exists for any of the four documents. The final cross-contract consistency audit across all four documents is complete: every cross-reference, shared formula (`source_value_digest`, the marker-body `is_chunked`/`chunk_count` extension, the §6.3 nonce/AAD wording), and status flag was checked for mutual agreement; two mechanical citation-drift notes and three substantive gaps (S5-B3's chunk-locator operation-identity binding; §10.4.1's disposition-registry exhaustiveness; and Gate 7's resulting class-level-vs-instance-level contradiction that the exhaustiveness fix itself introduced) were corrected in this same change (see the header status line above), and no further inconsistency was found. This is **`S5_A_ADMITTED / S5_B1_ADMITTED / S5_B2_ADMITTED / S5_B3_ADMITTED / CONTRACT_DEFINED / IMPLEMENTATION_NOT_STARTED`**, not `IMPLEMENTATION_READY`; `S5_TERMINAL` was declared YES in a dedicated follow-up after PR #584 merged with green post-merge main CI. Gate 1a is re-admitted (QNB-100) and implemented headless (see the header status line); Gate 1b is decided (Option C, §8.2/§8.2.1) and implemented headless with the platform secure-store adapter; Gate 2 (the typed identity registry, the identity-bound record codec and the record-class disposition), Gate 3 slice 3A (durable staging and promotion), slice 3B (the `record-commit` marker codec and the marker commit protocol with startup reconciliation) and slice 3C parts 1, 2, 3a and 3b (the authority-root digests, the record-catalog descriptors and pages, the root slot, pointer and key-epoch record encodings, and the two-phase root commit) are admitted and implemented headless, while the rest of Gate 3 and Gates 4–7 remain unadmitted, and no production authority switch is allowed. Current desktop filesystem authority remains unchanged and current user data is not retroactively encrypted by any of these documents. +This S5-A baseline, together with S5-B1/S5-B2/S5-B3, is admitted at the semantic level for everything each actually specifies (protected records/representations enumerated; logical identity, envelope, key/epoch, parse, failure, and downgrade semantics explicit; durable writes, generations/commit markers, admission, lock, recovery, and memory bounds defined; canonical migration-source/payload evidence, race-free `AuthoritySnapshot` lifetime, and the chunked large-object envelope all admitted above; Core-vs-platform responsibilities and headless tests explicit; #357/#359/#360/#361 have implementation owners and closure evidence) but is **not** implementation-ready: no production implementation exists for any of the four documents. The final cross-contract consistency audit across all four documents is complete: every cross-reference, shared formula (`source_value_digest`, the marker-body `is_chunked`/`chunk_count` extension, the §6.3 nonce/AAD wording), and status flag was checked for mutual agreement; two mechanical citation-drift notes and three substantive gaps (S5-B3's chunk-locator operation-identity binding; §10.4.1's disposition-registry exhaustiveness; and Gate 7's resulting class-level-vs-instance-level contradiction that the exhaustiveness fix itself introduced) were corrected in this same change (see the header status line above), and no further inconsistency was found. This is **`S5_A_ADMITTED / S5_B1_ADMITTED / S5_B2_ADMITTED / S5_B3_ADMITTED / CONTRACT_DEFINED / IMPLEMENTATION_NOT_STARTED`**, not `IMPLEMENTATION_READY`; `S5_TERMINAL` was declared YES in a dedicated follow-up after PR #584 merged with green post-merge main CI. Gate 1a is re-admitted (QNB-100) and implemented headless (see the header status line); Gate 1b is decided (Option C, §8.2/§8.2.1) and implemented headless with the platform secure-store adapter; Gate 2 (the typed identity registry, the identity-bound record codec and the record-class disposition), Gate 3 slice 3A (durable staging and promotion), slice 3B (the `record-commit` marker codec and the marker commit protocol with startup reconciliation) and slice 3C parts 1, 2, 3a, 3b and 3c-1 (the authority-root digests, the record-catalog descriptors and pages, the root slot, pointer and key-epoch record encodings, and the two-phase root commit) are admitted and implemented headless, while the rest of Gate 3 and Gates 4–7 remain unadmitted, and no production authority switch is allowed. Current desktop filesystem authority remains unchanged and current user data is not retroactively encrypted by any of these documents.