diff --git a/README.md b/README.md index 52d6060..673a8fe 100644 --- a/README.md +++ b/README.md @@ -488,3 +488,49 @@ prove all-tuples coherence, deployed principal mapping or old reader/writer exclusion. Before any mutation an enforcing server must atomically recheck fresh authorization against the observed original principal/store. Do not serialize observations, log identities/proofs or treat status as permission to migrate. + +### Immutable migration proposal (Rust source preparation) + +`BackupReadSession::prepare_migration` consumes references to the original session's +app and LDK status observations, original logical `WALLET` observation and present +app component observations. Components must already be safe to make immutable; +ordinary mutable app keys, including same-key USDT retries that re-encrypt, cannot +be substituted for immutable snapshots. This semantic remains the app's duty. +LDK operational objects are rejected as components, while LDK policy still +participates in the proposal. It preserves the legacy archive's encrypted key/AAD and +exact raw envelope digest; explicit absence is a separate condition. It encrypts +the supplied initial head once with the original app key, without uploading or +replacing any object. The app remains responsible for the head schema and complete, +coherent capture. An opaque wallet value alone cannot prove that completeness. + +Every domain must have a positive legacy status. Equal store IDs do not combine +identities: only an exact match of both reported principal and store coalesces app +and LDK into one physical tuple, retaining both domain labels. Duplicate/conflicting +component keys, destructive head/archive collisions, noncanonical UUIDs and inputs +outside the candidate server's activation limits are rejected. The digest uses the +server's UTF-8 tuple/key ordering and exact field encoding, including the encrypted +head and all archive/component conditions. It contains no renewable proof. + +Access the frozen result through `prepared_migration`. Another session cannot adopt +it, even with the same seed. Revocation or starting either domain's next status read +invalidates access and preparation from earlier status observations. Failed, +pending and abandoned status reads also invalidate earlier preparation; historical +status remains readable. Repeated access retains the same ciphertext/digest. Calling +preparation again requires a fresh UUID and creates different encrypted content, +not a retry of the old plan. This preparation API has no durable UUID registry; +future dispatch must preserve one exact attempt across uncertain outcomes. + +This content is neither persisted authority nor an execution API. Separate status +and object reads cannot prove a coherent physical snapshot or stable authorization +mapping between requests. A future enforcing server must atomically compare fresh +proofs against every original physical tuple and exact envelope condition before +mutation. Lifecycle drains, RN and running-node capabilities, old reader/writer +exclusion, durable uncertain-outcome reconciliation and app restoration remain +unfinished requirements. No activation request, new UniFFI API, release or deployed +server support is introduced here. Never log plaintext, proofs or private identities. + +Coalescing does not verify that both credential providers still map to the shared +tuple. The preserved server candidate accepts one proof per unique target; that +alone cannot recheck a second provider which has since remapped elsewhere. Retained +domain labels are necessary, but enforcing that condition remains unresolved before +any future activation. diff --git a/src/backup_session.rs b/src/backup_session.rs index 60c15a1..7d563b0 100644 --- a/src/backup_session.rs +++ b/src/backup_session.rs @@ -4,6 +4,12 @@ use crate::{LdkNamespace, LdkVssClient, MigrationState, VssClient, VssError, Vss use std::sync::Arc; use tokio::sync::watch; +mod preparation; +pub use preparation::{ + MigrationHeadInput, MigrationPreparation, MigrationPreparationError, PreparedMigration, + PreparedMigrationContent, PreparedMigrationHead, PreparedMigrationStore, +}; + /// One immutable storage/auth configuration for both native derivation domains. /// Equal store IDs do not establish equal physical server principals. pub struct BackupReadConfig { @@ -45,6 +51,7 @@ pub enum BackupDomain { /// Not serializable or Debug; it carries physical storage identity, not authority. pub struct MigrationObservation { owner: Arc>, + revision: Arc<()>, domain: BackupDomain, state: MigrationState, } @@ -78,6 +85,7 @@ pub struct BackupReadSession { app: VssClient, ldk: LdkVssClient, revoked: Arc>, + migration_revisions: [watch::Sender>; 2], } impl BackupReadSession { @@ -100,6 +108,7 @@ impl BackupReadSession { app, ldk, revoked: Arc::new(watch::channel(false).0), + migration_revisions: std::array::from_fn(|_| watch::channel(Arc::new(())).0), }) } @@ -168,6 +177,10 @@ impl BackupReadSession { ) -> Result { let mut cancellation = self.revoked.subscribe(); self.ensure_active()?; + // A pending, failed or abandoned refresh must not leave earlier policy + // observations admissible for a new proposal. + let revision = Arc::new(()); + self.migration_revisions[domain.index()].send_replace(Arc::clone(&revision)); let read = async { match domain { BackupDomain::App => self.app.read_migration_state().await, @@ -182,6 +195,7 @@ impl BackupReadSession { self.ensure_active()?; Ok(MigrationObservation { owner: Arc::clone(&self.revoked), + revision, domain, state: state?, }) @@ -208,6 +222,15 @@ impl BackupReadSession { } } +impl BackupDomain { + fn index(self) -> usize { + match self { + Self::App => 0, + Self::Ldk => 1, + } + } +} + impl Drop for BackupReadSession { fn drop(&mut self) { self.revoke(); diff --git a/src/backup_session/preparation.rs b/src/backup_session/preparation.rs new file mode 100644 index 0000000..23a40e7 --- /dev/null +++ b/src/backup_session/preparation.rs @@ -0,0 +1,332 @@ +//! Immutable proposal content, never migration execution or remote authority. + +use super::*; +use crate::{MigrationArchive, MigrationObject, MigrationPolicy}; +use bitcoin::hashes::{sha256, Hash}; +use prost::Message; + +mod canonical; + +const MAX_HEAD_BYTES: usize = 10 * 1024 * 1024; + +/// Plaintext for a new app head. Its schema and coherent capture remain app duties. +pub struct MigrationHeadInput<'a> { + pub key: &'a str, + pub value: &'a [u8], +} + +/// Both native domains must be observed, even when they share a physical tuple. +/// `archive` must be the original app WALLET observation, including explicit absence. +/// Components must be present app observations already safe to make immutable. +/// Mutable LDK operational keys are never components. No principal, digest or proof is supplied +/// by the caller. This input cannot establish running-node or legacy-reader exclusion. +pub struct MigrationPreparation<'a> { + pub migration_id: &'a str, + pub app_status: &'a MigrationObservation, + pub ldk_status: &'a MigrationObservation, + pub archive: &'a BackupReadObservation, + pub initial_head: MigrationHeadInput<'a>, + pub components: &'a [&'a BackupReadObservation], +} + +#[derive(Debug, thiserror::Error)] +pub enum MigrationPreparationError { + #[error(transparent)] + Ownership(#[from] BackupReadError), + #[error("A newer migration status read invalidated this observation")] + StalePolicy, + #[error("The observed store is already migrated")] + AlreadyMigrated, + #[error("Invalid migration preparation: {0}")] + InvalidInput(&'static str), +} + +/// Frozen ciphertext and expectations belonging to one original in-memory owner. +/// No serialization, Debug, credentials, automatic replay or activation method. +/// Access through the owning session; a new status read invalidates access even if +/// that read fails or returns the same policy. Revocation cannot retract copied data. +pub struct PreparedMigration { + owner: Arc>, + revisions: [Arc<()>; 2], + content: PreparedMigrationContent, +} + +pub struct PreparedMigrationContent { + migration_id: String, + proposal_sha256: [u8; 32], + stores: Vec, +} + +/// One unique physical principal/store pair in canonical UTF-8 order. +/// The domain list retains which original providers must participate. Two domains +/// are coalesced only when both reported the exact same principal AND store. +pub struct PreparedMigrationStore { + domains: Vec, + effective_principal: String, + store_id: String, + archive: Option, + initial_head: Option, + objects: Vec, +} + +pub struct PreparedMigrationHead { + key: String, + encrypted_value: Vec, +} + +impl BackupReadSession { + /// Encrypts the initial head once under this session's original app key/AAD. + /// Performs no I/O. An archive stays at its original encrypted key and is never + /// decrypted/re-encrypted here. A separately prepared proposal has new ciphertext; + /// only repeated access to THIS result preserves an exact attempt. Supply a fresh + /// UUID for each independent preparation; this API has no durable UUID/replay registry. + /// + /// These checks are local observations, not a coherent snapshot or permission to + /// write. Future execution requires fresh proofs checked atomically against the + /// original tuples and exact envelope conditions by an enforcing server, plus + /// application lifecycle drains and retained uncertain-outcome reconciliation. + pub fn prepare_migration( + &self, + input: MigrationPreparation<'_>, + ) -> Result { + self.ensure_active()?; + canonical::validate_id(input.migration_id)?; + let app = self.preparation_status(input.app_status, BackupDomain::App)?; + let ldk = self.preparation_status(input.ldk_status, BackupDomain::Ldk)?; + let mut stores = physical_stores(app, ldk)?; + let app_index = store_index(&stores, BackupDomain::App); + stores[app_index].archive = Some(self.prepare_archive(input.archive)?); + self.prepare_components(&mut stores, input.components)?; + stores[app_index].initial_head = Some(self.prepare_head(input.initial_head)?); + canonical::validate_objects(&mut stores)?; + let content = PreparedMigrationContent { + migration_id: input.migration_id.to_owned(), + proposal_sha256: canonical::digest(input.migration_id, &stores), + stores, + }; + let prepared = PreparedMigration { + owner: Arc::clone(&self.revoked), + revisions: [ + Arc::clone(&input.app_status.revision), + Arc::clone(&input.ldk_status.revision), + ], + content, + }; + self.prepared_migration(&prepared)?; + Ok(prepared) + } + + /// Checks original ownership, revocation and that neither status has been + /// superseded at this instant. The returned content is not execution authority. + pub fn prepared_migration<'a>( + &self, + prepared: &'a PreparedMigration, + ) -> Result<&'a PreparedMigrationContent, MigrationPreparationError> { + if !Arc::ptr_eq(&self.revoked, &prepared.owner) { + return Err(BackupReadError::DifferentOwner.into()); + } + self.ensure_active()?; + for (current, captured) in self.migration_revisions.iter().zip(&prepared.revisions) { + if !Arc::ptr_eq(¤t.borrow(), captured) { + return Err(MigrationPreparationError::StalePolicy); + } + } + Ok(&prepared.content) + } + + fn preparation_status<'a>( + &self, + observation: &'a MigrationObservation, + domain: BackupDomain, + ) -> Result<&'a MigrationState, MigrationPreparationError> { + let state = self.migration_state(observation)?; + if observation.domain != domain { + return Err(invalid("Status credential domain does not match")); + } + if !Arc::ptr_eq( + &self.migration_revisions[domain.index()].borrow(), + &observation.revision, + ) { + return Err(MigrationPreparationError::StalePolicy); + } + if !matches!(state.policy, MigrationPolicy::Legacy) { + return Err(MigrationPreparationError::AlreadyMigrated); + } + Ok(state) + } + + fn prepare_archive( + &self, + observation: &BackupReadObservation, + ) -> Result { + let receipt = self.receipt(observation)?; + if !matches!(&observation.target, BackupReadTarget::App { key } if key == "WALLET") { + return Err(invalid("Archive must be the original app WALLET")); + } + Ok(MigrationArchive { + key: self.app.build_key("WALLET"), + ciphertext_sha256: receipt.map(receipt_digest).transpose()?, + }) + } + + fn prepare_components( + &self, + stores: &mut [PreparedMigrationStore], + observations: &[&BackupReadObservation], + ) -> Result<(), MigrationPreparationError> { + if observations.len() > 32 { + return Err(invalid("Too many component observations")); + } + for observation in observations { + let receipt = self + .receipt(observation)? + .ok_or(invalid("A component is absent"))?; + if !matches!(observation.target, BackupReadTarget::App { .. }) { + return Err(invalid( + "LDK operational objects cannot be immutable components", + )); + } + let store = &mut stores[store_index(stores, BackupDomain::App)]; + if receipt.store_id != store.store_id { + return Err(invalid("Component store does not match observed policy")); + } + store.objects.push(MigrationObject { + key: receipt.storage_key.clone(), + ciphertext_sha256: receipt_digest(receipt)?, + }); + } + Ok(()) + } + + fn prepare_head( + &self, + input: MigrationHeadInput<'_>, + ) -> Result { + if input.key.is_empty() + || input.key.len() > 600 + || input.value.is_empty() + || input.value.len() > MAX_HEAD_BYTES + { + return Err(invalid( + "Initial head must have a key and bounded nonempty content", + )); + } + let key = self.app.build_key(input.key); + canonical::valid_key(&key)?; + let value = self + .app + .storable_builder + .build( + input.value.to_vec(), + 1, + &self.app.app_data_encryption_key, + key.as_bytes(), + ) + .encode_to_vec(); + if value.len() > MAX_HEAD_BYTES { + return Err(invalid("Encrypted initial head exceeds server limit")); + } + Ok(PreparedMigrationHead { + key, + encrypted_value: value, + }) + } +} + +fn physical_stores( + app: &MigrationState, + ldk: &MigrationState, +) -> Result, MigrationPreparationError> { + let mut stores: Vec = Vec::new(); + for (domain, state) in [(BackupDomain::App, app), (BackupDomain::Ldk, ldk)] { + if state.effective_principal.is_empty() + || state.effective_principal.len() > 120 + || state.store_id.is_empty() + || state.store_id.len() > 120 + { + return Err(invalid( + "Migration identities must fit the server's byte limits", + )); + } + if let Some(store) = stores.iter_mut().find(|s| { + s.effective_principal == state.effective_principal && s.store_id == state.store_id + }) { + store.domains.push(domain); + } else { + stores.push(PreparedMigrationStore { + domains: vec![domain], + effective_principal: state.effective_principal.clone(), + store_id: state.store_id.clone(), + archive: None, + initial_head: None, + objects: Vec::new(), + }); + } + } + stores.sort_by(|a, b| { + (&a.effective_principal, &a.store_id).cmp(&(&b.effective_principal, &b.store_id)) + }); + Ok(stores) +} + +fn store_index(stores: &[PreparedMigrationStore], domain: BackupDomain) -> usize { + stores + .iter() + .position(|s| s.domains.contains(&domain)) + .expect("Both original domains were retained") +} + +fn receipt_digest(receipt: &VssReadReceipt) -> Result<[u8; 32], MigrationPreparationError> { + receipt + .envelope_sha256 + .parse::() + .map(|h| h.to_byte_array()) + .map_err(|_| invalid("Invalid observed ciphertext digest")) +} + +fn invalid(reason: &'static str) -> MigrationPreparationError { + MigrationPreparationError::InvalidInput(reason) +} + +impl PreparedMigrationContent { + pub fn migration_id(&self) -> &str { + &self.migration_id + } + pub fn proposal_sha256(&self) -> &[u8; 32] { + &self.proposal_sha256 + } + pub fn stores(&self) -> &[PreparedMigrationStore] { + &self.stores + } +} + +impl PreparedMigrationStore { + pub fn domains(&self) -> &[BackupDomain] { + &self.domains + } + pub fn effective_principal(&self) -> &str { + &self.effective_principal + } + pub fn store_id(&self) -> &str { + &self.store_id + } + pub fn archive(&self) -> Option<&MigrationArchive> { + self.archive.as_ref() + } + pub fn initial_head(&self) -> Option<&PreparedMigrationHead> { + self.initial_head.as_ref() + } + pub fn objects(&self) -> &[MigrationObject] { + &self.objects + } +} + +impl PreparedMigrationHead { + pub fn key(&self) -> &str { + &self.key + } + /// Exact envelope for an expected-version-zero insert, with authenticated version one. + pub fn encrypted_value(&self) -> &[u8] { + &self.encrypted_value + } +} diff --git a/src/backup_session/preparation/canonical.rs b/src/backup_session/preparation/canonical.rs new file mode 100644 index 0000000..52ecdce --- /dev/null +++ b/src/backup_session/preparation/canonical.rs @@ -0,0 +1,104 @@ +use super::*; +use bitcoin::hashes::HashEngine; + +pub(super) fn validate_id(id: &str) -> Result<(), MigrationPreparationError> { + if id.len() != 36 + || !id.bytes().enumerate().all(|(i, b)| { + if [8, 13, 18, 23].contains(&i) { + b == b'-' + } else { + b.is_ascii_digit() || (b'a'..=b'f').contains(&b) + } + }) + { + return Err(invalid("Migration ID must be a canonical lowercase UUID")); + } + Ok(()) +} + +pub(super) fn validate_objects( + stores: &mut [PreparedMigrationStore], +) -> Result<(), MigrationPreparationError> { + for store in stores { + if store.objects.len() > 32 { + return Err(invalid("Too many components in one physical store")); + } + if let Some(archive) = &store.archive { + valid_key(&archive.key)?; + } + if let Some(head) = &store.initial_head { + valid_key(&head.key)?; + if store.archive.as_ref().is_some_and(|a| a.key == head.key) { + return Err(invalid("Initial head must not replace the archive")); + } + } + store.objects.sort_by(|a, b| a.key.cmp(&b.key)); + if store.objects.windows(2).any(|p| p[0].key == p[1].key) { + return Err(invalid("Duplicate component key in a physical store")); + } + for object in &store.objects { + valid_key(&object.key)?; + if store + .initial_head + .as_ref() + .is_some_and(|h| h.key == object.key) + { + return Err(invalid("Initial head conflicts with a component")); + } + if store.archive.as_ref().is_some_and(|a| { + a.key == object.key && a.ciphertext_sha256 != Some(object.ciphertext_sha256) + }) { + return Err(invalid("Archive and component observations conflict")); + } + } + } + Ok(()) +} + +pub(super) fn valid_key(key: &str) -> Result<(), MigrationPreparationError> { + if key.is_empty() || key.len() > 600 || key == "global_version" || key == "vss_global_version" { + return Err(invalid("Invalid encrypted storage key")); + } + Ok(()) +} + +// Candidate server's canonical encoding; no proofs or local domain labels are hashed. +// Callers have already sorted unique physical tuples and component keys. +pub(super) fn digest(id: &str, stores: &[PreparedMigrationStore]) -> [u8; 32] { + let mut hash = sha256::Hash::engine(); + hash.input(b"vss-store-migration-v1\0"); + field(&mut hash, id.as_bytes()); + hash.input(&(stores.len() as u32).to_be_bytes()); + for store in stores { + field(&mut hash, store.effective_principal.as_bytes()); + field(&mut hash, store.store_id.as_bytes()); + hash.input(&0_i64.to_be_bytes()); + hash.input(&[u8::from(store.archive.is_some())]); + if let Some(archive) = &store.archive { + field(&mut hash, archive.key.as_bytes()); + hash.input(&[u8::from(archive.ciphertext_sha256.is_some())]); + if let Some(digest) = &archive.ciphertext_sha256 { + field(&mut hash, digest); + } + } + hash.input(&[u8::from(store.initial_head.is_some())]); + if let Some(head) = &store.initial_head { + field(&mut hash, head.key.as_bytes()); + field(&mut hash, &head.encrypted_value); + } + hash.input(&(store.objects.len() as u32).to_be_bytes()); + for object in &store.objects { + field(&mut hash, object.key.as_bytes()); + field(&mut hash, &object.ciphertext_sha256); + } + } + sha256::Hash::from_engine(hash).to_byte_array() +} + +fn field(hash: &mut sha256::HashEngine, bytes: &[u8]) { + hash.input(&(bytes.len() as u32).to_be_bytes()); + hash.input(bytes); +} + +#[cfg(test)] +mod tests; diff --git a/src/backup_session/preparation/canonical/tests.rs b/src/backup_session/preparation/canonical/tests.rs new file mode 100644 index 0000000..c60aa05 --- /dev/null +++ b/src/backup_session/preparation/canonical/tests.rs @@ -0,0 +1,158 @@ +use super::*; + +const ID: &str = "9b1b6d9f-4a9e-40ca-8e77-c1d8e6dd48fb"; + +fn vector_store(principal: &str) -> PreparedMigrationStore { + PreparedMigrationStore { + domains: vec![BackupDomain::App], + effective_principal: principal.into(), + store_id: "wallet".into(), + archive: Some(MigrationArchive { + key: "WALLET".into(), + ciphertext_sha256: None, + }), + initial_head: Some(PreparedMigrationHead { + key: "HEAD".into(), + encrypted_value: b"encrypted".to_vec(), + }), + objects: vec![], + } +} + +#[test] +fn matches_frozen_rust_java_server_vectors() { + // Independent values from the preserved server migration_tests.rs / Java vectors. + for (principals, expected) in [ + ( + ["app", "ldk"], + "6dd7b9e48c13a00e3e8c6eec6ce1e84c4982416334bcc0b2421ffef0b3697d05", + ), + ( + ["\u{e000}", "\u{10000}"], + "36a2158d68c2f784d7fc2f21d3a5e6b65d41c0c0306df48e834885edd5b28375", + ), + ] { + let stores = principals.map(vector_store); + assert_eq!( + digest(ID, &stores), + expected.parse::().unwrap().to_byte_array() + ); + } +} + +#[test] +fn production_sort_matches_present_archive_component_unicode_vector() { + let app = MigrationState { + effective_principal: "\u{10000}".into(), + store_id: "wallet".into(), + policy: MigrationPolicy::Legacy, + }; + let ldk = MigrationState { + effective_principal: "\u{e000}".into(), + store_id: "wallet".into(), + policy: MigrationPolicy::Legacy, + }; + let mut stores = physical_stores(&app, &ldk).unwrap(); + let index = store_index(&stores, BackupDomain::App); + stores[index].archive = Some(MigrationArchive { + key: "WALLET".into(), + ciphertext_sha256: Some([7; 32]), + }); + stores[index].initial_head = Some(PreparedMigrationHead { + key: "HEAD".into(), + encrypted_value: b"encrypted".to_vec(), + }); + stores[index].objects = vec![ + MigrationObject { + key: "component-\u{10000}".into(), + ciphertext_sha256: [5; 32], + }, + MigrationObject { + key: "component-\u{e000}".into(), + ciphertext_sha256: [3; 32], + }, + ]; + validate_objects(&mut stores).unwrap(); + assert_eq!(stores[0].effective_principal, "\u{e000}"); + assert_eq!(stores[index].objects[0].key, "component-\u{e000}"); + // Independently encoded from the preserved server contract: 305 preimage bytes. + let expected = "e2edca39e0c7f6b96cdf4f06fb8bedc6a1a219229505d8628b482ee5afa72250" + .parse::() + .unwrap(); + assert_eq!(digest(ID, &stores), expected.to_byte_array()); +} + +#[test] +fn canonical_digest_binds_every_immutable_field() { + let mut stores = [vector_store("app"), vector_store("ldk")]; + let initial = digest(ID, &stores); + stores[1].archive.as_mut().unwrap().ciphertext_sha256 = Some([7; 32]); + let present = digest(ID, &stores); + assert_ne!(initial, present); + stores[1].objects.push(MigrationObject { + key: "component".into(), + ciphertext_sha256: [3; 32], + }); + let component = digest(ID, &stores); + assert_ne!(present, component); + stores[1].objects[0].ciphertext_sha256[0] ^= 1; + assert_ne!(component, digest(ID, &stores)); + let before_head = digest(ID, &stores); + stores[0] + .initial_head + .as_mut() + .unwrap() + .encrypted_value + .push(0); + assert_ne!(before_head, digest(ID, &stores)); + let before_principal = digest(ID, &stores); + stores[0].effective_principal.push('x'); + assert_ne!(before_principal, digest(ID, &stores)); + let before_store = digest(ID, &stores); + stores[0].store_id.push('x'); + assert_ne!(before_store, digest(ID, &stores)); + assert_ne!( + digest(ID, &stores), + digest("9b1b6d9f-4a9e-40ca-8e77-c1d8e6dd48fa", &stores) + ); +} + +#[test] +fn rejects_duplicate_and_conflicting_physical_keys() { + let mut store = vector_store("app"); + store.objects.push(MigrationObject { + key: "WALLET".into(), + ciphertext_sha256: [3; 32], + }); + assert!(validate_objects(std::slice::from_mut(&mut store)).is_err()); + store.archive.as_mut().unwrap().ciphertext_sha256 = Some([3; 32]); + validate_objects(std::slice::from_mut(&mut store)).unwrap(); + store.objects.push(MigrationObject { + key: "WALLET".into(), + ciphertext_sha256: [3; 32], + }); + assert!(validate_objects(std::slice::from_mut(&mut store)).is_err()); + store.objects.pop(); + store.objects[0].key = "HEAD".into(); + assert!(validate_objects(std::slice::from_mut(&mut store)).is_err()); + store.objects.clear(); + store.initial_head.as_mut().unwrap().key = "WALLET".into(); + assert!(validate_objects(std::slice::from_mut(&mut store)).is_err()); +} + +#[test] +fn component_limit_is_per_physical_store() { + let mut store = vector_store("app"); + store.objects = (0..32) + .map(|n| MigrationObject { + key: format!("key{n}"), + ciphertext_sha256: [3; 32], + }) + .collect(); + validate_objects(std::slice::from_mut(&mut store)).unwrap(); + store.objects.push(MigrationObject { + key: "extra".into(), + ciphertext_sha256: [3; 32], + }); + assert!(validate_objects(std::slice::from_mut(&mut store)).is_err()); +} diff --git a/src/backup_session/tests.rs b/src/backup_session/tests.rs index a140205..8292e86 100644 --- a/src/backup_session/tests.rs +++ b/src/backup_session/tests.rs @@ -1,4 +1,5 @@ use super::*; +mod preparation; use bitcoin::{ hashes::{sha256, Hash}, hex::FromHex, diff --git a/src/backup_session/tests/preparation.rs b/src/backup_session/tests/preparation.rs new file mode 100644 index 0000000..daa7d30 --- /dev/null +++ b/src/backup_session/tests/preparation.rs @@ -0,0 +1,516 @@ +use super::*; +use vss_client_ng::types::Storable; + +const ID: &str = "9b1b6d9f-4a9e-40ca-8e77-c1d8e6dd48fb"; + +struct Evidence { + app: MigrationObservation, + ldk: MigrationObservation, + archive: BackupReadObservation, +} + +impl Evidence { + async fn read(session: &BackupReadSession) -> Self { + Self { + app: session + .read_migration_state(BackupDomain::App) + .await + .unwrap(), + ldk: session + .read_migration_state(BackupDomain::Ldk) + .await + .unwrap(), + archive: session.read(app(), 4096).await.unwrap(), + } + } + + fn input<'a>( + &'a self, + components: &'a [&'a BackupReadObservation], + ) -> MigrationPreparation<'a> { + MigrationPreparation { + migration_id: ID, + app_status: &self.app, + ldk_status: &self.ldk, + archive: &self.archive, + initial_head: MigrationHeadInput { + key: "backup/head/v1", + value: b"coherent app snapshot", + }, + components, + } + } +} + +fn assert_invalid(result: Result) { + assert!(matches!( + result, + Err(MigrationPreparationError::InvalidInput(_)) + )); +} + +#[tokio::test] +async fn preparation_retains_exact_archive_and_app_components_without_io() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let mut archive = fixture.app_object(&session).await; + // Unknown protobuf fields must remain part of the exact archive digest. + archive.value.extend_from_slice(&[0xf8, 0x07, 0x2a]); + fixture + .objects + .lock() + .await + .insert(archive.key.clone(), archive.clone()); + let component_key = session.app.build_key("snapshot/component"); + let component = KeyValue { + key: component_key.clone(), + version: 1, + value: session + .app + .storable_builder + .build( + b"immutable snapshot".to_vec(), + 1, + &session.app.app_data_encryption_key, + component_key.as_bytes(), + ) + .encode_to_vec(), + }; + fixture + .objects + .lock() + .await + .insert(component.key.clone(), component.clone()); + let evidence = Evidence::read(&session).await; + let component_read = session + .read( + BackupReadTarget::App { + key: "snapshot/component".into(), + }, + 4096, + ) + .await + .unwrap(); + let before = ( + fixture.requests.lock().await.len(), + fixture.status_requests.lock().await.len(), + fixture.auth_keys.lock().await.len(), + ); + let prepared = session + .prepare_migration(evidence.input(&[&component_read, &evidence.archive])) + .unwrap(); + let content = session.prepared_migration(&prepared).unwrap(); + assert_eq!(content.migration_id(), ID); + assert_eq!(content.stores().len(), 2); + let app_store = content + .stores() + .iter() + .find(|s| s.domains() == [BackupDomain::App]) + .unwrap(); + let ldk_store = content + .stores() + .iter() + .find(|s| s.domains() == [BackupDomain::Ldk]) + .unwrap(); + assert_eq!(app_store.store_id(), ldk_store.store_id()); + assert_ne!( + app_store.effective_principal(), + ldk_store.effective_principal() + ); + let preserved = app_store.archive().unwrap(); + assert_eq!(preserved.key, archive.key); + assert_eq!( + preserved.ciphertext_sha256, + Some(sha256::Hash::hash(&archive.value).to_byte_array()) + ); + let archived_component = app_store + .objects() + .iter() + .find(|o| o.key == archive.key) + .unwrap(); + assert_eq!( + archived_component.ciphertext_sha256, + preserved.ciphertext_sha256.unwrap() + ); + let snapshot = app_store + .objects() + .iter() + .find(|o| o.key == component.key) + .unwrap(); + assert_eq!( + snapshot.ciphertext_sha256, + sha256::Hash::hash(&component.value).to_byte_array() + ); + assert!(ldk_store.objects().is_empty()); + assert!(ldk_store.archive().is_none() && ldk_store.initial_head().is_none()); + assert_eq!( + content.proposal_sha256(), + session + .prepared_migration(&prepared) + .unwrap() + .proposal_sha256() + ); + assert_eq!( + before, + ( + fixture.requests.lock().await.len(), + fixture.status_requests.lock().await.len(), + fixture.auth_keys.lock().await.len() + ) + ); + assert_eq!( + fixture.objects.lock().await[&archive.key].value, + archive.value + ); + assert_eq!(fixture.objects.lock().await.len(), 2); + tokio::task::yield_now().await; + assert!(!fixture.task.is_finished()); +} + +#[tokio::test] +async fn preparation_encrypts_head_once_with_original_app_key_and_aad() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let evidence = Evidence::read(&session).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + let content = session.prepared_migration(&prepared).unwrap(); + let head = content + .stores() + .iter() + .find_map(|s| s.initial_head()) + .unwrap(); + assert_eq!(head.key(), session.app.build_key("backup/head/v1")); + let envelope = Storable::decode(head.encrypted_value()).unwrap(); + let (plain, version) = session + .app + .storable_builder + .deconstruct( + envelope.clone(), + &session.app.app_data_encryption_key, + head.key().as_bytes(), + ) + .unwrap(); + assert_eq!(plain, b"coherent app snapshot"); + assert_eq!(version, 1); + assert!(session + .app + .storable_builder + .deconstruct( + envelope.clone(), + &session.ldk.data_encryption_key, + head.key().as_bytes() + ) + .is_err()); + assert!(session + .app + .storable_builder + .deconstruct(envelope, &session.app.app_data_encryption_key, b"wrong key") + .is_err()); + let repeated = session + .prepared_migration(&prepared) + .unwrap() + .stores() + .iter() + .find_map(|s| s.initial_head()) + .unwrap(); + assert_eq!(head.encrypted_value(), repeated.encrypted_value()); + let mut independent_input = evidence.input(&[]); + independent_input.migration_id = "9b1b6d9f-4a9e-40ca-8e77-c1d8e6dd48fa"; + let independent = session.prepare_migration(independent_input).unwrap(); + assert_ne!( + content.proposal_sha256(), + session + .prepared_migration(&independent) + .unwrap() + .proposal_sha256() + ); +} + +#[tokio::test] +async fn preparation_preserves_explicit_absent_archive_but_rejects_missing_component() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let evidence = Evidence::read(&session).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + let archive = session + .prepared_migration(&prepared) + .unwrap() + .stores() + .iter() + .find_map(|s| s.archive()) + .unwrap(); + assert_eq!(archive.key, session.app.build_key("WALLET")); + assert!(archive.ciphertext_sha256.is_none()); + assert_invalid(session.prepare_migration(evidence.input(&[&evidence.archive]))); +} + +#[tokio::test] +async fn preparation_cannot_obtain_absence_from_an_unreadable_wallet() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let mut row = fixture.app_object(&session).await; + row.value = b"invalid encrypted envelope".to_vec(); + fixture + .objects + .lock() + .await + .insert(row.key.clone(), row.clone()); + assert!(session.read(app(), 4096).await.is_err()); + fixture.objects.lock().await.remove(&row.key); + let evidence = Evidence::read(&session).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + assert!(session + .prepared_migration(&prepared) + .unwrap() + .stores() + .iter() + .find_map(|s| s.archive()) + .unwrap() + .ciphertext_sha256 + .is_none()); +} + +#[tokio::test] +async fn preparation_refuses_mutable_ldk_components_and_wrong_archive_domain() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + fixture.ldk_object(&session, &LdkNamespace::Monitors).await; + let evidence = Evidence::read(&session).await; + let operational = session + .read(ldk(LdkNamespace::Monitors), 4096) + .await + .unwrap(); + assert!(session.receipt(&operational).unwrap().is_some()); + assert_invalid(session.prepare_migration(evidence.input(&[&operational]))); + let mut wrong_archive = evidence.input(&[]); + wrong_archive.archive = &operational; + assert_invalid(session.prepare_migration(wrong_archive)); +} + +#[tokio::test] +async fn preparation_bounds_encrypted_head_key_and_value() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let evidence = Evidence::read(&session).await; + let boundary = "k".repeat(406); + let too_long = "k".repeat(407); + let unbounded = "k".repeat(601); + assert_eq!(session.app.build_key(&boundary).len(), 600); + for key in [&too_long, &unbounded] { + let mut input = evidence.input(&[]); + input.initial_head.key = key; + assert_invalid(session.prepare_migration(input)); + } + let mut accepted = evidence.input(&[]); + accepted.initial_head.key = &boundary; + session.prepare_migration(accepted).unwrap(); + let value = vec![1; 10 * 1024 * 1024]; + let mut maximum_plaintext = evidence.input(&[]); + maximum_plaintext.initial_head.value = &value; + assert_invalid(session.prepare_migration(maximum_plaintext)); + let mut bounded = evidence.input(&[]); + bounded.initial_head.value = &value[..value.len() - 1024]; + let prepared = session.prepare_migration(bounded).unwrap(); + let head = session + .prepared_migration(&prepared) + .unwrap() + .stores() + .iter() + .find_map(|s| s.initial_head()) + .unwrap(); + assert!(head.encrypted_value().len() <= value.len()); +} + +#[tokio::test] +async fn preparation_refuses_replacement_owner_for_every_observation_and_result() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let replacement = fixture.session([42; 64]).await; + let evidence = Evidence::read(&session).await; + let other = Evidence::read(&replacement).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + let mut wrong_status = evidence.input(&[]); + wrong_status.ldk_status = &other.ldk; + let mut wrong_archive = evidence.input(&[]); + wrong_archive.archive = &other.archive; + for input in [ + wrong_status, + wrong_archive, + evidence.input(&[&other.archive]), + ] { + assert!(matches!( + session.prepare_migration(input), + Err(MigrationPreparationError::Ownership( + BackupReadError::DifferentOwner + )) + )); + } + assert!(matches!( + replacement.prepared_migration(&prepared), + Err(MigrationPreparationError::Ownership( + BackupReadError::DifferentOwner + )) + )); + session.revoke(); + assert!(matches!( + session.prepare_migration(evidence.input(&[])), + Err(MigrationPreparationError::Ownership( + BackupReadError::Revoked + )) + )); + assert!(matches!( + session.prepared_migration(&prepared), + Err(MigrationPreparationError::Ownership( + BackupReadError::Revoked + )) + )); +} + +#[tokio::test] +async fn preparation_refuses_wrong_domain_uuid_and_destructive_head() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + fixture.app_object(&session).await; + let evidence = Evidence::read(&session).await; + let mut wrong_domain = evidence.input(&[]); + wrong_domain.app_status = &evidence.ldk; + assert_invalid(session.prepare_migration(wrong_domain)); + for id in [ + "", + "9B1b6d9f-4a9e-40ca-8e77-c1d8e6dd48fb", + "9b1b6d9f_4a9e-40ca-8e77-c1d8e6dd48fb", + "9b1b6d9f-4a9e-40ca-8e77-c1d8e6dd48fg", + ] { + let mut input = evidence.input(&[]); + input.migration_id = id; + assert_invalid(session.prepare_migration(input)); + } + for key in ["", "WALLET"] { + let mut input = evidence.input(&[]); + input.initial_head.key = key; + assert_invalid(session.prepare_migration(input)); + } + assert_invalid( + session.prepare_migration(evidence.input(&[&evidence.archive, &evidence.archive])), + ); +} + +#[tokio::test] +async fn preparation_rejects_changed_or_unavailable_policy_and_keeps_historical_readable() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let evidence = Evidence::read(&session).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + *fixture.status_response.lock().await = Some((503, b"unavailable".to_vec())); + assert!(session + .read_migration_state(BackupDomain::Ldk) + .await + .is_err()); + assert!(session.migration_state(&evidence.ldk).is_ok()); + assert!(matches!( + session.prepared_migration(&prepared), + Err(MigrationPreparationError::StalePolicy) + )); + assert!(matches!( + session.prepare_migration(evidence.input(&[])), + Err(MigrationPreparationError::StalePolicy) + )); + *fixture.status_response.lock().await = None; + let fresh = Evidence::read(&session).await; + session.prepare_migration(fresh.input(&[])).unwrap(); +} + +#[tokio::test] +async fn preparation_pending_then_abandoned_status_cannot_reuse_previous_policy() { + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let evidence = Evidence::read(&session).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + *fixture.hold.lock().await = Some("/getStoreMigrationState".into()); + let mut read = Box::pin(session.read_migration_state(BackupDomain::App)); + tokio::select! { _ = fixture.received.notified() => {}, _ = &mut read => panic!("Expected held body") } + assert!(matches!( + session.prepared_migration(&prepared), + Err(MigrationPreparationError::StalePolicy) + )); + drop(read); + fixture.release.notify_one(); + assert!(matches!( + session.prepare_migration(evidence.input(&[])), + Err(MigrationPreparationError::StalePolicy) + )); +} + +#[tokio::test] +async fn preparation_rejects_committed_policy_without_legacy_fallback() { + use crate::migration_status::wire; + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + let mut evidence = Evidence::read(&session).await; + *fixture.status_response.lock().await = Some(( + 200, + wire::State { + epoch: 1, + required_protocol: "bitkit-migration-v1".into(), + migration_id: ID.into(), + proposal_sha256: vec![4; 32], + effective_principal: evidence.ldk.state.effective_principal.clone(), + store_id: "owned-store".into(), + ..Default::default() + } + .encode_to_vec(), + )); + evidence.ldk = session + .read_migration_state(BackupDomain::Ldk) + .await + .unwrap(); + assert!(matches!( + session.prepare_migration(evidence.input(&[])), + Err(MigrationPreparationError::AlreadyMigrated) + )); +} + +#[tokio::test] +async fn preparation_coalesces_only_exact_physical_tuple_and_keeps_both_domains() { + use crate::migration_status::wire; + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + *fixture.status_response.lock().await = Some(( + 200, + wire::State { + effective_principal: "mapped-principal".into(), + store_id: "owned-store".into(), + ..Default::default() + } + .encode_to_vec(), + )); + let evidence = Evidence::read(&session).await; + let prepared = session.prepare_migration(evidence.input(&[])).unwrap(); + let stores = session.prepared_migration(&prepared).unwrap().stores(); + assert_eq!(stores.len(), 1); + assert_eq!(stores[0].domains(), &[BackupDomain::App, BackupDomain::Ldk]); + fixture.ldk_object(&session, &LdkNamespace::Monitors).await; + let operational = session + .read(ldk(LdkNamespace::Monitors), 4096) + .await + .unwrap(); + assert_invalid(session.prepare_migration(evidence.input(&[&operational]))); +} + +#[tokio::test] +async fn preparation_rejects_legacy_unicode_identity_beyond_activation_byte_limit() { + use crate::migration_status::wire; + let fixture = Fixture::new().await; + let session = fixture.session([42; 64]).await; + *fixture.status_response.lock().await = Some(( + 200, + wire::State { + effective_principal: "é".repeat(120), + store_id: "owned-store".into(), + ..Default::default() + } + .encode_to_vec(), + )); + let evidence = Evidence::read(&session).await; + assert_invalid(session.prepare_migration(evidence.input(&[]))); +} diff --git a/src/lib.rs b/src/lib.rs index 1db9752..b756264 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,7 +12,9 @@ mod types; pub use backup_session::{ BackupDomain, BackupReadConfig, BackupReadError, BackupReadObservation, BackupReadSession, - BackupReadTarget, MigrationObservation, + BackupReadTarget, MigrationHeadInput, MigrationObservation, MigrationPreparation, + MigrationPreparationError, PreparedMigration, PreparedMigrationContent, PreparedMigrationHead, + PreparedMigrationStore, }; pub use errors::*; pub use implementation::{derive_vss_store_id, VssClient};