diff --git a/CHANGELOG.md b/CHANGELOG.md index 715f54872..7e4931b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **R-15 Gate 3, slice 3C part 3a — root and key-epoch records (#445):** the storage contract now + fixes the exact byte formats for the stored root of trust, the small pointer to it, and the key + epoch records. The protected-storage core can seal, strictly check and open the root and key-epoch + records, and encode and strictly check the (unencrypted) pointer. Nothing is + committed through them yet; the commit sequence and its crash recovery follow. PR #943. - **R-15 Gate 3, slice 3C part 2 — record catalog pages (#445):** the storage contract now fixes how the authenticated record catalog is split into pages, and the protected-storage core can build, strictly check and seal those pages. Listing stored records will rely on this catalog instead of diff --git a/crates/worldscript-secure-storage/src/lib.rs b/crates/worldscript-secure-storage/src/lib.rs index 97b7d84f3..7b016fa4f 100644 --- a/crates/worldscript-secure-storage/src/lib.rs +++ b/crates/worldscript-secure-storage/src/lib.rs @@ -7,7 +7,8 @@ //! ([`record`]), the §10.4.1 record-class disposition ([`mod@disposition`]), the Gate 3 //! slice 3A durable staging and promotion of one generation ([`durable`]), and slice 3B's //! `record-commit` marker codec ([`marker`]) and marker commit protocol with startup reconciliation -//! ([`commit`]), and slice 3C's authority-root digests ([`root`]) and record catalog ([`catalog`]). +//! ([`commit`]), and slice 3C's authority-root digests ([`root`]), record catalog ([`catalog`]) +//! and persisted root records ([`root_record`]). //! It changes no current //! TypeScript/Tauri storage authority and holds no journal or authority-root commit yet. @@ -31,6 +32,7 @@ pub mod record; pub mod record_class; pub mod recovery; pub mod root; +pub mod root_record; pub mod seal; pub mod secure_store; pub mod store_authority; @@ -69,9 +71,13 @@ pub use record::{open_record, seal_record, OpenedRecord, ADMITTED_RECORD_SCHEMAS pub use record_class::RecordClass; pub use recovery::{unwrap_recovery, wrap_recovery, RecoveryMaterial, UnwrappedRecovery}; pub use root::{ - catalog_set_digest, key_epoch_set_digest, marker_set_digest, pointer_digest, root_digest, - CatalogShard, KeyEpochEntry, LiveMigration, MarkerSetEntry, RootBody, RootCommitEvidence, - RootCommitState, RootError, + catalog_set_digest, decode_root_body, encode_root_body, key_epoch_set_digest, + marker_set_digest, pointer_digest, root_digest, CatalogShard, KeyEpochEntry, LiveMigration, + MarkerSetEntry, RootBody, RootCommitEvidence, RootCommitState, RootError, +}; +pub use root_record::{ + open_root_slot, seal_root_slot, KeyEpochAddress, KeyEpochRead, KeyEpochRecord, KeyEpochStatus, + KeyEpochWrite, RootPointer, RootRecordError, RootSlotRead, }; #[cfg(feature = "test-randomness")] pub use seal::seal_with_random; diff --git a/crates/worldscript-secure-storage/src/root.rs b/crates/worldscript-secure-storage/src/root.rs index 6f63f4e50..54780c95e 100644 --- a/crates/worldscript-secure-storage/src/root.rs +++ b/crates/worldscript-secure-storage/src/root.rs @@ -39,6 +39,8 @@ pub enum RootError { InvalidOperationId, /// A catalog shard outside `0..CATALOG_SHARD_COUNT` (§5.5.1): no valid page can exist for it. InvalidShard, + /// Truncated or malformed root bytes, including a non-canonical flag or state code. + Corrupt(&'static str), InvalidIdentity(AadError), } @@ -109,7 +111,7 @@ pub enum RootCommitState { } impl RootCommitState { - fn code(self) -> u32 { + pub fn code(self) -> u32 { match self { RootCommitState::NotCommitted => 0, RootCommitState::Committed => 1, @@ -218,12 +220,12 @@ fn keyed_set_digest( Ok(hasher.finalize().into()) } -/// `root_digest` (§5.4): the domain, then every root body field in the specified order. -pub fn root_digest(root: &RootBody) -> Result<[u8; 32], RootError> { +/// `canonical_root_body_bytes` (§5.3.4): every root body field in the §5.4 order. This is the +/// root slot's protected payload body and, after the domain prefix, exactly `root_digest`'s input. +pub fn encode_root_body(root: &RootBody) -> Result, RootError> { check_counter(root.root_generation)?; check_counter(root.active_key_epoch)?; let mut out = Vec::with_capacity(512); - out.extend_from_slice(ROOT_DOMAIN); out.extend_from_slice(&root.root_generation.to_be_bytes()); out.extend_from_slice(&root.active_key_epoch.to_be_bytes()); out.extend_from_slice(&root.root_key_ref_digest); @@ -235,7 +237,110 @@ pub fn root_digest(root: &RootBody) -> Result<[u8; 32], RootError> { out.extend_from_slice(&evidence.fencing_generation.to_be_bytes()); out.extend_from_slice(&evidence.state.code().to_be_bytes()); push_live_migration(&mut out, root.live_migration.as_ref())?; - Ok(Sha256::digest(&out).into()) + Ok(out) +} + +/// Strictly decodes `canonical_root_body_bytes`: every field is checked as on encoding, flags and +/// state codes must be canonical, no byte may follow, and the result re-encodes to the same bytes. +pub fn decode_root_body(bytes: &[u8]) -> Result { + let mut reader = Reader(bytes); + let root_generation = reader.u64()?; + let active_key_epoch = reader.u64()?; + let root_key_ref_digest = reader.digest()?; + let marker_set_digest = reader.digest()?; + let catalog_set_digest = reader.digest()?; + let key_epoch_set_digest = reader.digest()?; + let operation_id = reader.operation_id()?; + let fencing_generation = reader.u64()?; + let state = match reader.u32()? { + 0 => RootCommitState::NotCommitted, + 1 => RootCommitState::Committed, + _ => return Err(RootError::Corrupt("unknown root_commit_state_code")), + }; + let live_migration = match reader.u8()? { + 0 => None, + 1 => Some(LiveMigration { + operation_id: reader.operation_id()?, + fencing_generation: reader.u64()?, + journal_revision: reader.u64()?, + manifest_digest: reader.digest()?, + }), + _ => return Err(RootError::Corrupt("has_live_migration is neither 0 nor 1")), + }; + if !reader.0.is_empty() { + return Err(RootError::Corrupt("trailing bytes after the root body")); + } + let root = RootBody { + root_generation, + active_key_epoch, + root_key_ref_digest, + marker_set_digest, + catalog_set_digest, + key_epoch_set_digest, + commit_evidence: RootCommitEvidence { + operation_id, + fencing_generation, + state, + }, + live_migration, + }; + // Re-encoding applies every encoder check (counters, operation IDs, live-migration fence). + encode_root_body(&root)?; + Ok(root) +} + +/// A strict big-endian cursor: every read fails on truncation instead of padding. +struct Reader<'a>(&'a [u8]); + +impl<'a> Reader<'a> { + fn take(&mut self, len: usize) -> Result<&'a [u8], RootError> { + if self.0.len() < len { + return Err(RootError::Corrupt("truncated root body")); + } + let (head, tail) = self.0.split_at(len); + self.0 = tail; + Ok(head) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes( + self.take(4)?.try_into().expect("4 bytes"), + )) + } + + fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes( + self.take(8)?.try_into().expect("8 bytes"), + )) + } + + fn digest(&mut self) -> Result<[u8; 32], RootError> { + Ok(self.take(32)?.try_into().expect("32 bytes")) + } + + fn operation_id(&mut self) -> Result { + let len = self.u32()? as usize; + if len == 0 || len > MAX_OPERATION_ID_LEN { + return Err(RootError::InvalidOperationId); + } + std::str::from_utf8(self.take(len)?) + .map(str::to_owned) + .map_err(|_| RootError::Corrupt("operation_id is not UTF-8")) + } +} + +/// `root_digest` (§5.4): the domain, then the canonical root body. +pub fn root_digest(root: &RootBody) -> Result<[u8; 32], RootError> { + let body = encode_root_body(root)?; + Ok(Sha256::new() + .chain_update(ROOT_DOMAIN) + .chain_update(&body) + .finalize() + .into()) } /// `pointer_digest` (§5.4): binds an active-slot pointer to one committed root slot. diff --git a/crates/worldscript-secure-storage/src/root_record.rs b/crates/worldscript-secure-storage/src/root_record.rs new file mode 100644 index 000000000..d06b3a034 --- /dev/null +++ b/crates/worldscript-secure-storage/src/root_record.rs @@ -0,0 +1,375 @@ +//! Gate 3 slice 3C part 3a: the persisted authority-root records (§5.3, §5.3.4, §8.3). +//! +//! Three formats the two-phase root commit (§5.3.1) persists: +//! - a **root slot**: the root body sealed as generation `root_generation` of +//! `authority-root:` — the only place the authority root's fields live; +//! - the **active-slot pointer**: a small unencrypted file naming one slot, generation and root +//! digest, bound by `pointer_digest`. It holds no project content or key material and is +//! recoverable state only: the secure anchor always wins a disagreement (§5.3.1); +//! - a **key-epoch control record**: one epoch's status and key route, sealed as generation +//! `registry_generation` of `key-epoch::`; its `content_digest` is what +//! the root's `key_epoch_set_digest` binds. +//! +//! This module performs no I/O; the commit sequence, its crash recovery and cold start are 3C part +//! 3b. + +use crate::envelope::EnvelopeHeader; +use crate::error::{OpenError, SealError}; +use crate::identity::RecordIdentity; +use crate::marker::content_digest; +use crate::provider::{InstallationScopeId, RootKeyRefV1, RootSlot, MAX_ROOT_KEY_REF_LEN}; +use crate::record::{open_record, seal_record}; +use crate::record_class::RecordClass; +use crate::root::{ + decode_root_body, encode_root_body, pointer_digest, root_digest, KeyEpochEntry, RootBody, + RootError, +}; +use crate::seal::{Key, RecordMeta}; + +/// §5.3.4 version-1 constants. +pub const ROOT_SLOT_FORMAT_VERSION: u32 = 1; +pub const POINTER_FORMAT_VERSION: u32 = 1; +pub const KEY_EPOCH_RECORD_FORMAT_VERSION: u32 = 1; +/// The `record_schema` root slots and key-epoch records are sealed with. +pub const CONTROL_RECORD_SCHEMA: u32 = 1; +const POINTER_MAGIC: &[u8; 4] = b"WSRP"; + +/// Why a root record was refused. A refused record is never authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RootRecordError { + Root(RootError), + Corrupt(&'static str), + UnsupportedFormat(u32), + /// The record's envelope generation differs from the generation it is read as. + GenerationMismatch, + /// The pointer's `pointer_digest` does not bind its own slot, generation and root digest. + PointerDigestMismatch, + /// A key-epoch status outside §8.3's version-1 codes. + UnknownKeyEpochStatus(u32), + InvalidIdentity, + Seal(SealError), + Open(OpenError), +} + +impl From for RootRecordError { + fn from(error: RootError) -> Self { + RootRecordError::Root(error) + } +} + +/// Seals `root` as its root slot: generation `root_generation` of `authority-root:`, under +/// the root's own `active_key_epoch`. The payload is `u32be(ROOT_SLOT_FORMAT_VERSION)` followed by +/// `canonical_root_body_bytes`. +pub fn seal_root_slot( + key: &Key, + scope: &InstallationScopeId, + root: &RootBody, +) -> Result, RootRecordError> { + let mut payload = ROOT_SLOT_FORMAT_VERSION.to_be_bytes().to_vec(); + payload.extend_from_slice(&encode_root_body(root)?); + let meta = RecordMeta { + key_epoch: root.active_key_epoch, + record_generation: root.root_generation, + record_schema: CONTROL_RECORD_SCHEMA, + }; + seal_record(key, &root_identity(scope)?, meta, &payload).map_err(RootRecordError::Seal) +} + +/// A root slot to open: the sealed bytes of generation `root_generation` of +/// `authority-root:`. +#[derive(Debug, Clone, Copy)] +pub struct RootSlotRead<'a> { + pub scope: &'a InstallationScopeId, + pub root_generation: u64, + pub envelope: &'a [u8], +} + +impl RootSlotRead<'_> { + /// The authenticated header must name this generation and the control-record schema. + fn check(&self, header: &EnvelopeHeader) -> Result<(), RootRecordError> { + check_schema(header)?; + if header.record_generation == self.root_generation { + Ok(()) + } else { + Err(RootRecordError::GenerationMismatch) + } + } +} + +/// Opens a root slot and returns the authenticated body with its `root_digest`. The envelope must +/// authenticate as `authority-root:` at the requested generation, and its header must agree +/// with the body it carries (same generation, same epoch). +pub fn open_root_slot( + key: &Key, + read: &RootSlotRead<'_>, +) -> Result<(RootBody, [u8; 32]), RootRecordError> { + let opened = open_record(key, &root_identity(read.scope)?, read.envelope) + .map_err(RootRecordError::Open)?; + read.check(&opened.header)?; + let root = decode_root_body(versioned::(&opened.payload)?)?; + if root.root_generation != read.root_generation { + return Err(RootRecordError::GenerationMismatch); + } + if opened.header.key_epoch != root.active_key_epoch { + return Err(RootRecordError::Corrupt( + "root slot sealed under another epoch", + )); + } + let digest = root_digest(&root)?; + Ok((root, digest)) +} + +/// The active-slot pointer (§5.3, §5.3.4): which committed slot, generation and root digest the +/// filesystem currently names. Recoverable state only; never publication authority (§5.3.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RootPointer { + pub slot: RootSlot, + pub root_generation: u64, + pub root_digest: [u8; 32], +} + +impl RootPointer { + /// `WSRP`, `u32be(POINTER_FORMAT_VERSION)`, `u8(slot)`, `u64be(root_generation)`, the root + /// digest, then `pointer_digest` over those three fields. + pub fn encode(&self) -> Result, RootRecordError> { + let binding = pointer_digest(self.slot, self.root_generation, &self.root_digest)?; + let mut out = Vec::with_capacity(4 + 4 + 1 + 8 + 32 + 32); + out.extend_from_slice(POINTER_MAGIC); + out.extend_from_slice(&POINTER_FORMAT_VERSION.to_be_bytes()); + out.push(self.slot.code()); + out.extend_from_slice(&self.root_generation.to_be_bytes()); + out.extend_from_slice(&self.root_digest); + out.extend_from_slice(&binding); + Ok(out) + } + + /// Strictly decodes a pointer: exact length, magic and version, a valid slot code, an assigned + /// generation, and a `pointer_digest` that binds the other fields. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() != 4 + 4 + 1 + 8 + 32 + 32 { + return Err(RootRecordError::Corrupt("pointer has the wrong length")); + } + if &bytes[..4] != POINTER_MAGIC { + return Err(RootRecordError::Corrupt("not a root pointer")); + } + let version = u32::from_be_bytes(bytes[4..8].try_into().expect("4 bytes")); + if version != POINTER_FORMAT_VERSION { + return Err(RootRecordError::UnsupportedFormat(version)); + } + let slot = RootSlot::from_code(bytes[8]) + .map_err(|_| RootRecordError::Corrupt("unknown root slot code"))?; + let root_generation = u64::from_be_bytes(bytes[9..17].try_into().expect("8 bytes")); + let root_digest: [u8; 32] = bytes[17..49].try_into().expect("32 bytes"); + let pointer = RootPointer { + slot, + root_generation, + root_digest, + }; + let expected = pointer_digest(slot, root_generation, &root_digest)?; + if bytes[49..] == expected { + Ok(pointer) + } else { + Err(RootRecordError::PointerDigestMismatch) + } + } +} + +/// Key-epoch status (§8.3, version 1). No status `0`; an unknown code is refused. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyEpochStatus { + Prepared, + Active, + RetiredRecoveryOnly, + Revoked, +} + +impl KeyEpochStatus { + pub fn code(self) -> u32 { + match self { + KeyEpochStatus::Prepared => 1, + KeyEpochStatus::Active => 2, + KeyEpochStatus::RetiredRecoveryOnly => 3, + KeyEpochStatus::Revoked => 4, + } + } + + fn from_code(code: u32) -> Result { + match code { + 1 => Ok(KeyEpochStatus::Prepared), + 2 => Ok(KeyEpochStatus::Active), + 3 => Ok(KeyEpochStatus::RetiredRecoveryOnly), + 4 => Ok(KeyEpochStatus::Revoked), + other => Err(RootRecordError::UnknownKeyEpochStatus(other)), + } + } +} + +/// One immutable generation of a key-epoch control record (§5.4, §8.3): the epoch, its status and +/// the opaque, non-secret key route. Never key material. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyEpochRecord { + pub epoch: u64, + pub status: KeyEpochStatus, + pub root_key_ref: RootKeyRefV1, +} + +impl KeyEpochRecord { + /// `u32be(KEY_EPOCH_RECORD_FORMAT_VERSION)`, `u64be(epoch)`, `u32be(status)`, then the key + /// route as `u32be(byte_length)` + bytes. + pub fn encode(&self) -> Result, RootRecordError> { + if self.epoch == 0 || self.epoch == u64::MAX { + return Err(RootRecordError::Root(RootError::InvalidCounter)); + } + // `RootKeyRefV1` already guarantees 1..=MAX_ROOT_KEY_REF_LEN bytes. + let route = self.root_key_ref.as_bytes(); + let mut out = KEY_EPOCH_RECORD_FORMAT_VERSION.to_be_bytes().to_vec(); + out.extend_from_slice(&self.epoch.to_be_bytes()); + out.extend_from_slice(&self.status.code().to_be_bytes()); + out.extend_from_slice(&(route.len() as u32).to_be_bytes()); + out.extend_from_slice(route); + Ok(out) + } + + pub fn decode(bytes: &[u8]) -> Result { + let body = versioned::(bytes)?; + let Some((fixed, _)) = body.split_first_chunk::<12>() else { + return Err(RootRecordError::Corrupt("truncated key-epoch record")); + }; + let epoch = u64::from_be_bytes(fixed[..8].try_into().expect("8 bytes")); + let status_code = u32::from_be_bytes(fixed[8..].try_into().expect("4 bytes")); + let record = KeyEpochRecord { + epoch, + status: KeyEpochStatus::from_code(status_code)?, + root_key_ref: key_route(&body[12..])?, + }; + record.encode()?; + Ok(record) + } + + /// Seals this generation at `write.address` (its epoch must be this record's) under + /// `write.key_epoch`. + pub fn seal(&self, key: &Key, write: &KeyEpochWrite<'_>) -> Result, RootRecordError> { + let address = write.address; + // The address's own counters are validated before it is compared with the record. + let identity = address.identity()?; + if address.epoch != self.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, + }; + seal_record(key, &identity, meta, &self.encode()?).map_err(RootRecordError::Seal) + } + + /// Opens `read.envelope` at `read.address` and returns the record with the + /// `key_epoch_set_digest` entry its envelope supplies. + pub fn open( + key: &Key, + read: &KeyEpochRead<'_>, + ) -> Result<(Self, KeyEpochEntry), RootRecordError> { + let address = read.address; + let opened = + open_record(key, &address.identity()?, read.envelope).map_err(RootRecordError::Open)?; + address.check(&opened.header)?; + let record = KeyEpochRecord::decode(&opened.payload)?; + if record.epoch != address.epoch { + return Err(RootRecordError::Corrupt( + "key-epoch record names another epoch", + )); + } + let entry = KeyEpochEntry { + epoch: address.epoch, + registry_generation: address.registry_generation, + content_digest: content_digest(read.envelope), + }; + Ok((record, entry)) + } +} + +/// A key-epoch record generation to seal: where, and under which data epoch's key. +#[derive(Debug, Clone, Copy)] +pub struct KeyEpochWrite<'a> { + pub address: KeyEpochAddress<'a>, + pub key_epoch: u64, +} + +/// A sealed key-epoch record generation to open. +#[derive(Debug, Clone, Copy)] +pub struct KeyEpochRead<'a> { + pub address: KeyEpochAddress<'a>, + pub envelope: &'a [u8], +} + +/// Where one key-epoch record generation lives: `key-epoch::` at +/// `registry_generation`. Both counters are checked before anything uses them. +#[derive(Debug, Clone, Copy)] +pub struct KeyEpochAddress<'a> { + pub scope: &'a InstallationScopeId, + pub epoch: u64, + pub registry_generation: u64, +} + +impl KeyEpochAddress<'_> { + fn identity(&self) -> Result { + let assigned = |value: u64| value != 0 && value != u64::MAX; + if !(assigned(self.epoch) && assigned(self.registry_generation)) { + return Err(RootRecordError::Root(RootError::InvalidCounter)); + } + let epoch = self.epoch.to_string(); + RecordIdentity::new(RecordClass::KeyEpoch, &[self.scope.as_str(), &epoch]) + .map_err(|_| RootRecordError::InvalidIdentity) + } + + /// The authenticated header must name this generation and the control-record schema. + fn check(&self, header: &EnvelopeHeader) -> Result<(), RootRecordError> { + check_schema(header)?; + if header.record_generation == self.registry_generation { + Ok(()) + } else { + Err(RootRecordError::GenerationMismatch) + } + } +} + +/// The key route that must be exactly the rest of the record: `u32be(byte_length)` + bytes. +fn key_route(rest: &[u8]) -> Result { + let Some((len, route)) = rest.split_first_chunk::<4>() else { + return Err(RootRecordError::Corrupt("truncated key-epoch record")); + }; + let len = u32::from_be_bytes(*len) as usize; + let in_bounds = (1..=MAX_ROOT_KEY_REF_LEN).contains(&len) && route.len() == len; + if !in_bounds { + return Err(RootRecordError::Corrupt("key route length out of bounds")); + } + RootKeyRefV1::new(route.to_vec()).map_err(|_| RootRecordError::Corrupt("invalid key route")) +} + +fn root_identity(scope: &InstallationScopeId) -> Result { + RecordIdentity::new(RecordClass::AuthorityRoot, &[scope.as_str()]) + .map_err(|_| RootRecordError::InvalidIdentity) +} + +fn check_schema(header: &EnvelopeHeader) -> Result<(), RootRecordError> { + if header.record_schema == CONTROL_RECORD_SCHEMA { + Ok(()) + } else { + Err(RootRecordError::UnsupportedFormat(header.record_schema)) + } +} + +/// The payload after its leading `u32be(format_version)`, which must be `VERSION`. +fn versioned(payload: &[u8]) -> Result<&[u8], RootRecordError> { + let Some((head, body)) = payload.split_first_chunk::<4>() else { + return Err(RootRecordError::Corrupt("truncated record payload")); + }; + let found = u32::from_be_bytes(*head); + if found == VERSION { + Ok(body) + } else { + Err(RootRecordError::UnsupportedFormat(found)) + } +} diff --git a/crates/worldscript-secure-storage/tests/gate3c_root_record_test.rs b/crates/worldscript-secure-storage/tests/gate3c_root_record_test.rs new file mode 100644 index 000000000..ad1fbffe2 --- /dev/null +++ b/crates/worldscript-secure-storage/tests/gate3c_root_record_test.rs @@ -0,0 +1,340 @@ +//! Gate 3 slice 3C part 3a: the persisted authority-root records (§5.3, §5.3.4, §8.3). +//! +//! Byte layouts are assembled from the contract's field lists; the root and pointer digests are the +//! independently pinned 3C part 1 vectors. + +use worldscript_secure_storage::{ + decode_root_body, encode_root_body, open_root_slot, seal_record, seal_root_slot, + InstallationScopeId, Key, KeyEpochAddress, KeyEpochRecord, KeyEpochStatus, LiveMigration, + OpenError, RecordClass, RecordIdentity, RecordMeta, RootBody, RootCommitEvidence, + RootCommitState, RootError, RootKeyRefV1, RootPointer, RootRecordError, RootSlot, +}; +use worldscript_secure_storage::{KeyEpochRead, KeyEpochWrite, RootSlotRead}; + +fn key() -> Key { + Key::from_bytes(&mut [4u8; 32]) +} + +fn scope() -> InstallationScopeId { + InstallationScopeId::from_random_bits([9u8; 16]) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// The 3C part 1 vector root (its `root_digest` is pinned there). +fn root() -> RootBody { + RootBody { + root_generation: 5, + active_key_epoch: 2, + root_key_ref_digest: [0xa1; 32], + marker_set_digest: [0xa2; 32], + catalog_set_digest: [0xa3; 32], + key_epoch_set_digest: [0xa4; 32], + commit_evidence: RootCommitEvidence { + operation_id: "0123456789abcdef0123456789abcdef".to_owned(), + fencing_generation: 0, + state: RootCommitState::Committed, + }, + live_migration: None, + } +} + +fn slot_read<'a>( + scope: &'a InstallationScopeId, + root_generation: u64, + envelope: &'a [u8], +) -> RootSlotRead<'a> { + RootSlotRead { + scope, + root_generation, + envelope, + } +} + +const ROOT_DIGEST: &str = "199c2a845547d946e9d769f6aea9cc724c12c1aa3a7a7cbd5d7d5958e07aefc1"; +const POINTER_DIGEST: &str = "58196ba16c117c0f4d4178c183b33135c65aaad3d36cbc661aab93532e40fe7c"; + +#[test] +fn a_root_slot_round_trips_and_yields_the_pinned_root_digest() { + let sealed = seal_root_slot(&key(), &scope(), &root()).unwrap(); + let (opened, digest) = open_root_slot(&key(), &slot_read(&scope(), 5, &sealed)).unwrap(); + assert_eq!((opened, hex(&digest)), (root(), ROOT_DIGEST.to_owned())); +} + +#[test] +fn a_root_slot_opens_only_as_its_own_scope_and_generation() { + let sealed = seal_root_slot(&key(), &scope(), &root()).unwrap(); + let other = InstallationScopeId::from_random_bits([1u8; 16]); + let refusals = ( + open_root_slot(&key(), &slot_read(&scope(), 6, &sealed)).unwrap_err(), + open_root_slot(&key(), &slot_read(&other, 5, &sealed)).unwrap_err(), + ); + assert_eq!( + refusals, + ( + RootRecordError::GenerationMismatch, + RootRecordError::Open(OpenError::Tampered) + ) + ); +} + +#[test] +fn the_root_body_decoder_is_strict_and_canonical() { + let mut live = root(); + live.live_migration = Some(LiveMigration { + operation_id: "migration-op-1".to_owned(), + fencing_generation: 3, + journal_revision: 0, + manifest_digest: [0xa5; 32], + }); + for body in [root(), live] { + let bytes = encode_root_body(&body).unwrap(); + assert_eq!(decode_root_body(&bytes).unwrap(), body); + for len in 0..bytes.len() { + assert!(decode_root_body(&bytes[..len]).is_err(), "prefix {len}"); + } + let mut trailing = bytes.clone(); + trailing.push(0); + assert!(decode_root_body(&trailing).is_err()); + } +} + +#[test] +fn the_root_body_decoder_refuses_non_canonical_codes_and_counters() { + let bytes = encode_root_body(&root()).unwrap(); + // Layout tail: … u32 root_commit_state_code | u8 has_live_migration. + let state_at = bytes.len() - 1 - 4; + let mut state = bytes.clone(); + state[state_at..state_at + 4].copy_from_slice(&2u32.to_be_bytes()); + let mut flag = bytes.clone(); + *flag.last_mut().unwrap() = 2; + let mut generation = bytes.clone(); + generation[..8].copy_from_slice(&0u64.to_be_bytes()); + let refusals = [state, flag, generation].map(|b| decode_root_body(&b).unwrap_err()); + assert_eq!( + refusals, + [ + RootError::Corrupt("unknown root_commit_state_code"), + RootError::Corrupt("has_live_migration is neither 0 nor 1"), + RootError::InvalidCounter, + ] + ); +} + +fn pointer() -> RootPointer { + RootPointer { + slot: RootSlot::B, + root_generation: 5, + root_digest: [0xa6; 32], + } +} + +#[test] +fn the_pointer_matches_the_contract_layout_and_round_trips() { + let mut expected = b"WSRP".to_vec(); + expected.extend_from_slice(&1u32.to_be_bytes()); + expected.push(1); // ROOT_SLOT_B + expected.extend_from_slice(&5u64.to_be_bytes()); + expected.extend_from_slice(&[0xa6; 32]); + expected.extend_from_slice( + &(0..32) + .map(|i| u8::from_str_radix(&POINTER_DIGEST[2 * i..2 * i + 2], 16).unwrap()) + .collect::>(), + ); + let bytes = pointer().encode().unwrap(); + assert_eq!(bytes, expected); + assert_eq!(RootPointer::decode(&bytes).unwrap(), pointer()); +} + +#[test] +fn the_pointer_decoder_refuses_every_malformed_or_unbound_pointer() { + let bytes = pointer().encode().unwrap(); + let edited = |at: usize, value: u8| { + let mut out = bytes.clone(); + out[at] = value; + RootPointer::decode(&out).unwrap_err() + }; + let mut short = bytes.clone(); + short.pop(); + let refusals = [ + RootPointer::decode(&short).unwrap_err(), + edited(0, b'X'), + edited(7, 2), + edited(8, 2), + edited(20, 0), + ]; + assert_eq!( + refusals, + [ + RootRecordError::Corrupt("pointer has the wrong length"), + RootRecordError::Corrupt("not a root pointer"), + RootRecordError::UnsupportedFormat(2), + RootRecordError::Corrupt("unknown root slot code"), + RootRecordError::PointerDigestMismatch, + ] + ); +} + +fn route(bytes: &[u8]) -> RootKeyRefV1 { + RootKeyRefV1::new(bytes.to_vec()).unwrap() +} + +fn epoch_record() -> KeyEpochRecord { + KeyEpochRecord { + epoch: 1, + status: KeyEpochStatus::Active, + root_key_ref: route(b"route-1"), + } +} + +#[test] +fn a_key_epoch_record_matches_the_contract_layout_and_round_trips() { + let mut expected = 1u32.to_be_bytes().to_vec(); + expected.extend_from_slice(&1u64.to_be_bytes()); + expected.extend_from_slice(&2u32.to_be_bytes()); // KEY_EPOCH_ACTIVE + expected.extend_from_slice(&7u32.to_be_bytes()); + expected.extend_from_slice(b"route-1"); + let bytes = epoch_record().encode().unwrap(); + assert_eq!(bytes, expected); + assert_eq!(KeyEpochRecord::decode(&bytes).unwrap(), epoch_record()); +} + +#[test] +fn a_key_epoch_record_refuses_unknown_statuses_and_bad_routes() { + let bytes = epoch_record().encode().unwrap(); + let status = |code: u32| { + let mut out = bytes.clone(); + out[12..16].copy_from_slice(&code.to_be_bytes()); + KeyEpochRecord::decode(&out).unwrap_err() + }; + assert_eq!( + [status(0), status(5)], + [ + RootRecordError::UnknownKeyEpochStatus(0), + RootRecordError::UnknownKeyEpochStatus(5) + ] + ); + let mut long = bytes[..16].to_vec(); + long.extend_from_slice(&257u32.to_be_bytes()); + long.extend_from_slice(&[0x61; 257]); + assert_eq!( + KeyEpochRecord::decode(&long).unwrap_err(), + RootRecordError::Corrupt("key route length out of bounds") + ); +} + +fn address( + scope: &InstallationScopeId, + epoch: u64, + registry_generation: u64, +) -> KeyEpochAddress<'_> { + KeyEpochAddress { + scope, + epoch, + registry_generation, + } +} + +/// Seals `epoch_record()` at `(epoch, registry_generation)` under data epoch 1. +fn seal_epoch( + scope: &InstallationScopeId, + epoch: u64, + registry_generation: u64, +) -> Result, RootRecordError> { + let write = KeyEpochWrite { + address: address(scope, epoch, registry_generation), + key_epoch: 1, + }; + epoch_record().seal(&key(), &write) +} + +/// Opens `sealed` as the record at `(epoch, registry_generation)`. +fn open_epoch( + scope: &InstallationScopeId, + epoch: u64, + registry_generation: u64, + sealed: &[u8], +) -> Result<(KeyEpochRecord, worldscript_secure_storage::KeyEpochEntry), RootRecordError> { + let read = KeyEpochRead { + address: address(scope, epoch, registry_generation), + envelope: sealed, + }; + KeyEpochRecord::open(&key(), &read) +} + +#[test] +fn a_sealed_key_epoch_record_yields_its_set_entry() { + let scope = scope(); + let sealed = seal_epoch(&scope, 1, 3).unwrap(); + let (record, entry) = open_epoch(&scope, 1, 3, &sealed).unwrap(); + let digest = worldscript_secure_storage::content_digest(&sealed); + assert_eq!( + ( + record, + entry.epoch, + entry.registry_generation, + entry.content_digest + ), + (epoch_record(), 1, 3, digest) + ); +} + +#[test] +fn a_key_epoch_record_opens_only_at_its_own_epoch_and_generation() { + let scope = scope(); + let sealed = seal_epoch(&scope, 1, 3).unwrap(); + let refusals = [ + open_epoch(&scope, 2, 3, &sealed).unwrap_err(), + open_epoch(&scope, 1, 4, &sealed).unwrap_err(), + ]; + assert_eq!( + refusals, + [ + RootRecordError::Open(OpenError::Tampered), + RootRecordError::GenerationMismatch, + ] + ); + assert_eq!( + seal_epoch(&scope, 2, 3).unwrap_err(), + RootRecordError::Corrupt("key-epoch record names another epoch") + ); +} + +#[test] +fn key_epoch_addresses_follow_the_counter_lifecycle() { + let scope = scope(); + let sealed = seal_epoch(&scope, 1, 3).unwrap(); + let invalid = RootRecordError::Root(RootError::InvalidCounter); + // Both the epoch and the registry generation, on both seal and open. + for (epoch, generation) in [(0, 3), (u64::MAX, 3), (1, 0), (1, u64::MAX)] { + let refused = ( + seal_epoch(&scope, epoch, generation).unwrap_err(), + open_epoch(&scope, epoch, generation, &sealed).unwrap_err(), + ); + assert_eq!( + refused, + (invalid.clone(), invalid.clone()), + "({epoch}, {generation})" + ); + } +} + +#[test] +fn a_root_slot_sealed_under_another_epoch_than_its_body_is_refused() { + // A valid body (active_key_epoch 2) sealed by hand under envelope epoch 7. + let mut payload = 1u32.to_be_bytes().to_vec(); + payload.extend_from_slice(&encode_root_body(&root()).unwrap()); + let identity = RecordIdentity::new(RecordClass::AuthorityRoot, &[scope().as_str()]).unwrap(); + let meta = RecordMeta { + key_epoch: 7, + record_generation: 5, + record_schema: 1, + }; + let sealed = seal_record(&key(), &identity, meta, &payload).unwrap(); + assert_eq!( + open_root_slot(&key(), &slot_read(&scope(), 5, &sealed)).unwrap_err(), + RootRecordError::Corrupt("root slot sealed under another epoch") + ); +} diff --git a/docs/native/CORE-MIGRATION-LEDGER.md b/docs/native/CORE-MIGRATION-LEDGER.md index 2e4a3650b..32afd7b07 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–2) 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 the first two parts of slice 3C (the authority-root digests, §5.4, and the record-catalog descriptors and pages, §5.5/§5.5.1) implemented headless, with the two-phase root commit, `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_CATALOG_PAGES / 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–3a) 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–3a (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, and the root slot, pointer and key-epoch record encodings, §5.3.4) implemented headless, with the two-phase root commit, `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_RECORDS / 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 5e7245089..7c17151dd 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 and 2 (the authority-root digests, §5.4, and the record-catalog descriptors and pages, §5.5/§5.5.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 and 3a (the authority-root digests, §5.4, the record-catalog descriptors and pages, §5.5/§5.5.1, and the root slot, pointer and key-epoch record encodings, §5.3.4) = 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` @@ -749,6 +749,55 @@ reader snapshot still references. **S5-B2 admitted — race-free acquisition.** `docs/native/r15/AUTHORITY-SNAPSHOT-LIFETIME.md` (S5-B2) admits the atomic `AuthoritySnapshotGuard` acquisition this baseline originally left as an explicit blocker: reader algorithm step 2 (above) is `guard := acquire_authority_snapshot_guard()`, an operation indivisible with respect to a concurrent root commit's replacement of the current generation handle, closing the capture-to-registration race a reader could otherwise be descheduled inside. Reclamation eligibility (§3 of that document) extends this section's `ACTIVE_READER_PIN` reason precisely: a generation's reference count, maintained by the guard mechanism, must be zero in addition to satisfying this section's own retention conditions. +### 5.3.4 Root slot, pointer and key-epoch record encoding (version 1) + +§5.3 and §5.3.1 fix the root commit's state machine and §5.4 its digests; this subsection fixes the +three byte formats the commit persists. They are version-1 protocol constants; changing one requires +a new format version with an explicit compatibility rule. + +```text +ROOT_SLOT_FORMAT_VERSION = 1 +POINTER_FORMAT_VERSION = 1 +KEY_EPOCH_RECORD_FORMAT_VERSION = 1 +CONTROL_RECORD_SCHEMA = 1 record_schema of every root slot and key-epoch record +``` + +**Root slot.** A root generation is the protected record `authority-root:` +whose `record_generation` is the `root_generation` and whose envelope `key_epoch` is the body's own +`active_key_epoch`, sealed under the root key route (§5.3.1); opening refuses a slot whose header +generation or epoch differs from its body. Its +payload is `u32be(ROOT_SLOT_FORMAT_VERSION)` followed by `canonical_root_body_bytes`: exactly the +`root_digest` input of §5.4 after its domain prefix — `u64be(root_generation)`, +`u64be(active_key_epoch)`, `root_key_ref_digest`, `marker_set_digest`, `catalog_set_digest`, +`key_epoch_set_digest`, `root_commit_evidence`, then the live-migration binding — so +`root_digest = SHA-256("worldscript-r15/root/v1" || canonical_root_body_bytes)`. Decoding is strict: +every field is validated as on encoding, the state code is `0` or `1`, `has_live_migration` is `0` +or `1`, and no byte follows. §5.3's commit evidence "binds its operation ID, fencing generation, +journal revision, and `COMMITTED` state" through two fields of this one body: `root_commit_evidence` +binds the operation, fence and state, and the journal revision is the live-migration binding's +`live_migration_journal_revision` — present exactly when a migration/rekey journal is live. An +ordinary root commit has no journal and therefore binds no revision; §5.4's encoding is normative. + +**Active-slot pointer.** The pointer is an 81-byte unencrypted file holding no project content or key +material: `"WSRP"`, `u32be(POINTER_FORMAT_VERSION)`, `u8(slot code)` (§5.4 root-slot codes), +`u64be(root_generation)`, the 32-byte `root_digest`, then the 32-byte `pointer_digest` over those +three fields (§5.4). A pointer of another length, magic, version or slot code, or whose +`pointer_digest` does not bind its fields, is malformed. The pointer is recoverable state only: the +secure anchor's `committed_root` always wins a disagreement (§5.3.1, §5.3.3). + +**Key-epoch control record.** One generation of `key-epoch::`, whose +`record_generation` is its `registry_generation`. Its payload is +`u32be(KEY_EPOCH_RECORD_FORMAT_VERSION)`, `u64be(epoch)`, `u32be(status)` (§8.3: +`KEY_EPOCH_PREPARED = 1`, `KEY_EPOCH_ACTIVE = 2`, `KEY_EPOCH_RETIRED_RECOVERY_ONLY = 3`, +`KEY_EPOCH_REVOKED = 4`; any other code is refused), then the opaque, non-secret `RootKeyRefV1` key +route as `u32be(byte_length)` + bytes (1–256 bytes, §6.1.2). It carries no key material. The +verifier metadata, retirement detail, KDF profile reference and non-secret recovery metadata that §5.4 +permits a key-epoch payload to hold are reserved for a later format version; version 1 carries none +of them. The +record's `key_epoch_set_digest` entry is its `(epoch, registry_generation, content_digest)`, the +digest taken over the complete sealed envelope (§5.4). A record whose payload names another epoch +than its identity is refused. + ### 5.4 Canonical digest contract All R-15 digests use SHA-256 with a 32-byte output and an explicit ASCII domain-separation prefix. @@ -3504,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_CATALOG_PAGES +R15_GATE3=SLICE_3C_ROOT_RECORDS R15_GATE4=NOT_ADMITTED R15_GATE5=NOT_ADMITTED R15_GATE6=NOT_ADMITTED @@ -3712,6 +3761,18 @@ Later implementation may be admitted only in these bounded gates: page vectors are pinned from an independent implementation. Writing pages and the two-phase root commit wired into the write protocol, with `list_records` and retention, are the rest of slice 3C. + - **Slice 3C, part 3a (root slot, pointer and key-epoch record encodings)** — `root_record` in + `crates/worldscript-secure-storage` implements §5.3.4: a root slot seals the root body as + generation `root_generation` of `authority-root:` and opening returns the body with its + recomputed `root_digest`; the strict `decode_root_body` refuses truncation, trailing bytes, + non-canonical state and live-migration codes and invalid counters and re-encodes identically; + the 81-byte pointer is bound by `pointer_digest` and refused on any malformed field; a + key-epoch record carries epoch, §8.3 status and the opaque key route and yields its + `key_epoch_set_digest` entry from the sealed envelope. Opening a root slot or key-epoch record + refuses another scope, identity, generation or (for the root) envelope epoch; the pointer, + which carries no scope or identity, is recoverable state checked against the secure anchor. Nothing is persisted or committed yet: the two-phase root commit + (§5.3.1 A–G with its crash table and cold start), wired into the write protocol with + `list_records` and retention, is the rest of slice 3C. 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 @@ -3747,4 +3808,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 and 2 (the authority-root digests and the record-catalog descriptors and pages) 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 and 3a (the authority-root digests, the record-catalog descriptors and pages, and the root slot, pointer and key-epoch record encodings) 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.