diff --git a/crates/kult-crypto/src/device.rs b/crates/kult-crypto/src/device.rs index 294b77c..59f0cac 100644 --- a/crates/kult-crypto/src/device.rs +++ b/crates/kult-crypto/src/device.rs @@ -678,10 +678,25 @@ impl PendingDeviceLinkSource { Ok(link_material(&self.offer, response, shared.as_bytes()).1) } + /// Validate a confirmed approval before preparing its selected state. + pub fn validate_approval( + &self, + response: &DeviceLinkResponse, + confirmed: bool, + now: u64, + ) -> Result<()> { + self.offer.verify(now)?; + response.verify_for(&self.offer)?; + if !confirmed { + return Err(CryptoError::InvalidMessage); + } + Ok(()) + } + /// After explicit code confirmation, issue the device credential and /// encrypt the account root plus selected synchronized state to target. pub fn approve( - self, + &self, account: &Identity, response: &DeviceLinkResponse, confirmed: bool, @@ -689,9 +704,8 @@ impl PendingDeviceLinkSource { sync_payload: Vec, rng: &mut impl CryptoRngCore, ) -> Result { - self.offer.verify(now)?; - response.verify_for(&self.offer)?; - if !confirmed || sync_payload.len() > MAX_LINK_TRANSFER_BYTES { + self.validate_approval(response, confirmed, now)?; + if sync_payload.len() > MAX_LINK_TRANSFER_BYTES { return Err(CryptoError::InvalidMessage); } let shared = self @@ -713,21 +727,22 @@ impl PendingDeviceLinkSource { )?; let mut channel_root = [0u8; 32]; rng.fill_bytes(&mut channel_root); - let payload = LinkPackagePayload { - account_secret: account.to_bytes().to_vec(), - manifest: manifest.clone(), - certificate, - channel_root, - sync_payload, - }; - let plain = postcard::to_allocvec(&payload).map_err(|_| CryptoError::Serialization)?; - let package = StorageKey::from_bytes(*link_key).seal(LINK_PACKAGE_AD, &plain, rng); + let package = seal_device_link_recovery_package( + account, + &manifest, + &response.device.ed, + &channel_root, + &sync_payload, + &link_key, + rng, + )?; Ok(ApprovedDeviceLink { package, manifest, code, target_device: response.device.ed, channel_root: Zeroizing::new(channel_root), + recovery_key: link_key, }) } } @@ -785,7 +800,7 @@ impl PendingDeviceLinkTarget { /// After explicit local confirmation, authenticate and open the source's /// transfer package. Nothing is returned on mismatch or cancellation. pub fn complete( - self, + &self, package: &[u8], confirmed: bool, now: u64, @@ -851,6 +866,8 @@ pub struct ApprovedDeviceLink { pub target_device: [u8; 32], /// Shared sync-channel root retained only by the two linked devices. pub channel_root: Zeroizing<[u8; 32]>, + /// Transcript-derived package key retained only for durable return-value recovery. + pub recovery_key: Zeroizing<[u8; 32]>, } /// Target result after authenticating and opening a transfer package. @@ -880,6 +897,45 @@ struct LinkPackagePayload { sync_payload: Vec, } +/// Rebuild a committed device-link package after a caller restart lost the +/// original return value. The durable manifest and channel remain the source +/// of truth; the selected snapshot may be refreshed before resealing. +pub fn seal_device_link_recovery_package( + account: &Identity, + manifest: &DeviceManifest, + target_device: &[u8; 32], + channel_root: &[u8; 32], + sync_payload: &[u8], + link_key: &[u8; 32], + rng: &mut impl CryptoRngCore, +) -> Result> { + manifest.verify()?; + if manifest.account != account.public() + || *target_device == [0u8; 32] + || *channel_root == [0u8; 32] + || *link_key == [0u8; 32] + || sync_payload.len() > MAX_LINK_TRANSFER_BYTES + { + return Err(CryptoError::InvalidMessage); + } + let certificate = manifest + .devices + .iter() + .find(|entry| entry.certificate.device_id() == *target_device && entry.revoked_at.is_none()) + .map(|entry| entry.certificate.clone()) + .ok_or(CryptoError::InvalidMessage)?; + let payload = LinkPackagePayload { + account_secret: account.to_bytes().to_vec(), + manifest: manifest.clone(), + certificate, + channel_root: *channel_root, + sync_payload: sync_payload.to_vec(), + }; + let plain = + Zeroizing::new(postcard::to_allocvec(&payload).map_err(|_| CryptoError::Serialization)?); + Ok(StorageKey::from_bytes(*link_key).seal(LINK_PACKAGE_AD, &plain, rng)) +} + fn link_material( offer: &DeviceLinkOffer, response: &DeviceLinkResponse, diff --git a/crates/kult-crypto/src/lib.rs b/crates/kult-crypto/src/lib.rs index a607363..692021a 100644 --- a/crates/kult-crypto/src/lib.rs +++ b/crates/kult-crypto/src/lib.rs @@ -50,10 +50,11 @@ pub use call::{ MAX_CALL_MEDIA_FRAME_LEN, MAX_CALL_MEDIA_PAYLOAD_LEN, }; pub use device::{ - ApprovedDeviceLink, CompletedDeviceLink, DeviceCertificate, DeviceLinkCode, DeviceLinkOffer, - DeviceLinkResponse, DeviceManifest, DeviceManifestEntry, DevicePrekeyBundle, - PendingDeviceLinkSource, PendingDeviceLinkTarget, MAX_DEVICE_MANIFEST_ENTRIES, - MAX_DEVICE_NAME_BYTES, MAX_LINKED_DEVICES, MAX_LINK_TRANSFER_BYTES, + seal_device_link_recovery_package, ApprovedDeviceLink, CompletedDeviceLink, DeviceCertificate, + DeviceLinkCode, DeviceLinkOffer, DeviceLinkResponse, DeviceManifest, DeviceManifestEntry, + DevicePrekeyBundle, PendingDeviceLinkSource, PendingDeviceLinkTarget, + MAX_DEVICE_MANIFEST_ENTRIES, MAX_DEVICE_NAME_BYTES, MAX_LINKED_DEVICES, + MAX_LINK_TRANSFER_BYTES, }; pub use error::CryptoError; pub use fingerprint::{safety_number, SafetyNumber}; diff --git a/crates/kult-node/src/atomic_tests.rs b/crates/kult-node/src/atomic_tests.rs index 9ae0c4b..429c7cc 100644 --- a/crates/kult-node/src/atomic_tests.rs +++ b/crates/kult-node/src/atomic_tests.rs @@ -7,14 +7,15 @@ use std::sync::{ use async_trait::async_trait; use rand::{rngs::StdRng, SeedableRng}; -use kult_crypto::KdfProfile; +use kult_crypto::{GroupSenderChain, KdfProfile}; use kult_protocol::{Envelope, EnvelopeKind}; use kult_store::{ AttachmentStagePlan, AttachmentStatePlan, CommitFailpoint, CommitFailure, DeliveryState, - Direction, GroupDelivery, GroupMember, GroupMessageRecord, GroupRecord, GroupSendPlan, - GroupStatePlan, GroupStateTransition, GroupTransition, MediaDirection, MediaObjectRecord, + DeviceLinkPlan, DeviceProjection, DeviceProjectionPlan, DeviceStateTransition, Direction, + GroupDelivery, GroupMember, GroupMessageRecord, GroupRecord, GroupSendPlan, GroupStatePlan, + GroupStateTransition, GroupTransition, IdentityTransition, MediaDirection, MediaObjectRecord, MediaRecord, MediaScope, MediaTransferRecord, MediaTransferState, MediaTransferTransition, - MessageDeviceDeliveryRecord, MessageRecord, QueueClass, QueueItem, + MessageDeviceDeliveryRecord, MessageRecord, ProfileBootstrapPlan, QueueClass, QueueItem, Store, }; use kult_transport::{ CostClass, DeliveryHint, LatencyClass, LinkProfile, Reachability, SendReceipt, Transport, @@ -68,6 +69,7 @@ impl Transport for CountingTransport { #[derive(Clone, Copy, Debug)] enum Target { + ProfileBootstrap, PrekeyPublish, PairwiseSend, HandshakeReceive, @@ -81,6 +83,9 @@ enum Target { GroupReceive, AttachmentStage, AttachmentState, + DeviceControl, + DeviceLink, + DeviceProjection, } struct Fixture { @@ -205,6 +210,7 @@ fn run_store_case( seed: u64, ) -> bool { match target { + Target::ProfileBootstrap => run_profile_bootstrap(point, failure, seed), Target::PrekeyPublish => run_prekey_publish(point, failure, seed), Target::PairwiseSend => run_pairwise_send(point, failure, seed), Target::HandshakeReceive => run_handshake_receive(point, failure, seed), @@ -218,6 +224,9 @@ fn run_store_case( Target::GroupReceive => run_group_receive(point, failure, seed), Target::AttachmentStage => run_attachment_stage(point, failure, seed), Target::AttachmentState => run_attachment_state(point, failure, seed), + Target::DeviceControl => run_device_control(point, failure, seed), + Target::DeviceLink => run_device_link(point, failure, seed), + Target::DeviceProjection => run_device_projection(point, failure, seed), } } @@ -225,6 +234,55 @@ fn expected_committed(result_ok: bool, point: CommitFailpoint) -> bool { result_ok || point == CommitFailpoint::AfterCommit } +fn run_profile_bootstrap(point: CommitFailpoint, failure: CommitFailure, seed: u64) -> bool { + let mut rng = StdRng::seed_from_u64(seed); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("profile.db"); + let store = Store::create(&path, b"profile", TEST_KDF, &mut rng).unwrap(); + let identity = Identity::generate(&mut rng); + let device_state = devices::fresh_device_state(&identity, &mut rng).unwrap(); + let prekeys = PrekeyVault::generate(&mut rng).encode(); + store.arm_commit_failpoint(point, failure); + let result = store.commit_plan( + CommitPlan::ProfileBootstrap(ProfileBootstrapPlan { + identity: &identity, + device_state: &device_state, + prekeys: &prekeys, + }), + &mut rng, + ); + let result_ok = result.is_ok(); + let committed = store.get_identity().unwrap().is_some(); + assert_eq!(committed, expected_committed(result_ok, point)); + assert_eq!(store.get_device_state().unwrap().is_some(), committed); + assert_eq!(store.get_prekeys().unwrap().is_some(), committed); + drop(store); + + let store = Store::open(&path, b"profile").unwrap(); + if !committed { + store + .commit_plan( + CommitPlan::ProfileBootstrap(ProfileBootstrapPlan { + identity: &identity, + device_state: &device_state, + prekeys: &prekeys, + }), + &mut rng, + ) + .unwrap(); + } + assert_eq!( + store.get_identity().unwrap().unwrap().public(), + identity.public() + ); + assert_eq!(store.get_device_state().unwrap(), Some(device_state)); + assert_eq!( + store.get_prekeys().unwrap().unwrap().as_slice(), + prekeys.as_slice() + ); + result_ok +} + fn run_prekey_publish(point: CommitFailpoint, failure: CommitFailure, seed: u64) -> bool { let mut fixture = Fixture::new(seed); let before = fixture.alice.store.get_prekeys().unwrap().unwrap(); @@ -1189,6 +1247,191 @@ fn run_attachment_state(point: CommitFailpoint, failure: CommitFailure, seed: u6 result_ok } +fn run_device_control(point: CommitFailpoint, failure: CommitFailure, seed: u64) -> bool { + let mut fixture = Fixture::new(seed); + let device = fixture.alice.device_id(); + fixture.alice.arm_commit_failpoint(point, failure); + let result = fixture + .alice + .rename_linked_device(&device, "Renamed device", &mut fixture.rng); + let result_ok = result.is_ok(); + let committed = fixture + .alice + .store + .get_device_state() + .unwrap() + .unwrap() + .manifest + .devices + .iter() + .any(|entry| entry.certificate.device_id() == device && entry.name == "Renamed device"); + assert_eq!(committed, expected_committed(result_ok, point)); + assert!(result_ok || fixture.alice.drain_events().is_empty()); + + let Fixture { + _directory, + alice_path, + bob_path: _, + alice, + bob: _, + alice_id: _, + bob_id: _, + mut rng, + } = fixture; + drop(alice); + let mut alice = Node::open(&alice_path, b"alice").unwrap(); + if !committed { + alice + .rename_linked_device(&device, "Renamed device", &mut rng) + .unwrap(); + } + assert!(alice + .linked_devices() + .iter() + .any(|entry| entry.id == device && entry.name == "Renamed device")); + result_ok +} + +fn run_device_link(point: CommitFailpoint, failure: CommitFailure, seed: u64) -> bool { + let mut rng = StdRng::seed_from_u64(seed); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("target.db"); + let target = Node::create(&path, b"target", TEST_KDF, &mut rng).unwrap(); + let before_identity = Identity::from_bytes(&target.identity.to_bytes()); + let before_state = target.device_state.clone(); + let after_identity = Identity::generate(&mut rng); + let after_state = devices::fresh_device_state(&after_identity, &mut rng).unwrap(); + target.store.arm_commit_failpoint(point, failure); + let result = target.store.commit_plan( + CommitPlan::DeviceLink(DeviceLinkPlan { + identity: IdentityTransition { + before: &before_identity, + after: &after_identity, + }, + device_state: DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }, + contacts: &[], + devices: &[], + messages: &[], + groups: &[], + group_messages: &[], + authorities: &[], + local_metadata: &[], + notes: &[], + ephemeral: &[], + sync_events: &[], + presentation_changed: true, + }), + &mut rng, + ); + let result_ok = result.is_ok(); + let committed = + target.store.get_identity().unwrap().unwrap().public() == after_identity.public(); + assert_eq!(committed, expected_committed(result_ok, point)); + drop(target); + + let store = Store::open(&path, b"target").unwrap(); + if !committed { + store + .commit_plan( + CommitPlan::DeviceLink(DeviceLinkPlan { + identity: IdentityTransition { + before: &before_identity, + after: &after_identity, + }, + device_state: DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }, + contacts: &[], + devices: &[], + messages: &[], + groups: &[], + group_messages: &[], + authorities: &[], + local_metadata: &[], + notes: &[], + ephemeral: &[], + sync_events: &[], + presentation_changed: true, + }), + &mut rng, + ) + .unwrap(); + } + assert_eq!( + store.get_identity().unwrap().unwrap().public(), + after_identity.public() + ); + assert_eq!(store.get_device_state().unwrap(), Some(after_state)); + result_ok +} + +fn run_device_projection(point: CommitFailpoint, failure: CommitFailure, seed: u64) -> bool { + let mut fixture = Fixture::new(seed); + let before = fixture + .alice + .store + .get_contact(&fixture.bob_id) + .unwrap() + .unwrap(); + let mut after = before.clone(); + after.name = "Projected contact".to_owned(); + fixture.alice.arm_commit_failpoint(point, failure); + let result = fixture.alice.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[DeviceProjection::Contact { + before: Some(&before), + after: Some(&after), + }], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + &mut fixture.rng, + ); + let result_ok = result.is_ok(); + let committed = + fixture.alice.store.get_contact(&fixture.bob_id).unwrap() == Some(after.clone()); + assert_eq!(committed, expected_committed(result_ok, point)); + + let Fixture { + _directory, + alice_path, + bob_path: _, + alice, + bob: _, + alice_id: _, + bob_id: _, + mut rng, + } = fixture; + drop(alice); + let alice = Node::open(&alice_path, b"alice").unwrap(); + if !committed { + alice + .store + .commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[DeviceProjection::Contact { + before: Some(&before), + after: Some(&after), + }], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + &mut rng, + ) + .unwrap(); + } + assert_eq!(alice.store.get_contact(&after.peer).unwrap(), Some(after)); + result_ok +} + struct LargeGroupFanout { before_group: GroupRecord, after_group: GroupRecord, @@ -1911,6 +2154,7 @@ fn run_maintenance_reset_transition(point: TransitionFailpoint, seed: u64) -> bo #[test] fn every_transaction_statement_is_all_or_nothing_after_restart() { let targets = [ + Target::ProfileBootstrap, Target::PrekeyPublish, Target::PairwiseSend, Target::HandshakeReceive, @@ -1924,6 +2168,9 @@ fn every_transaction_statement_is_all_or_nothing_after_restart() { Target::GroupReceive, Target::AttachmentStage, Target::AttachmentState, + Target::DeviceControl, + Target::DeviceLink, + Target::DeviceProjection, ]; let mut seed = 0xa280_0000; for target in targets { @@ -2078,6 +2325,15 @@ fn stable_protocol_modules_cannot_call_raw_state_setters() { let forbidden = [ "put_session", "delete_session", + "put_identity", + "put_prekeys", + "put_device_state", + "put_device_sync_event", + "retain_device_sync_events", + "put_capabilities", + "delete_capabilities", + "delete_contact_device", + "retarget_message_device_deliveries", "put_group", "delete_group", "put_group_authority", @@ -2118,13 +2374,27 @@ fn stable_protocol_modules_cannot_call_raw_state_setters() { continue; } let filename = path.file_name().and_then(|value| value.to_str()).unwrap(); - if matches!(filename, "atomic_tests.rs" | "devices.rs") { - // C2 remains an explicit deferred transaction redesign under ADR-0026. + if filename == "atomic_tests.rs" { continue; } let source = std::fs::read_to_string(&path).unwrap(); let production = source.split("#[cfg(test)]").next().unwrap_or(&source); - let normalized = production.split_whitespace().collect::(); + let audited = if filename == "devices.rs" { + // The pre-C2 contact-admission compatibility bridge remains an + // explicit ADR-0030 quarantine. No other device path may inherit + // its raw migration setters. + let start = production + .find("pub(crate) fn apply_contact_device_manifest") + .expect("contact-admission quarantine start"); + let end = production[start..] + .find("pub(crate) fn account_for_device") + .map(|offset| start + offset) + .expect("contact-admission quarantine end"); + format!("{}{}", &production[..start], &production[end..]) + } else { + production.to_owned() + }; + let normalized = audited.split_whitespace().collect::(); assert!( !normalized.contains("#[cfg(any())]"), "{} retains disabled protocol code", @@ -2138,6 +2408,15 @@ fn stable_protocol_modules_cannot_call_raw_state_setters() { path.display() ); } + if filename == "devices.rs" { + let setter = "put_contact_device"; + let needle = format!(".{setter}("); + assert!( + !normalized.contains(&needle), + "{} bypasses a device transition through {setter}", + path.display() + ); + } } } @@ -2443,6 +2722,7 @@ fn reordered_deferred_and_duplicate_input_converges_after_restart() { #[test] fn disk_constraint_and_duplicate_failures_leave_retryable_inputs() { let targets = [ + Target::ProfileBootstrap, Target::PrekeyPublish, Target::PairwiseSend, Target::HandshakeReceive, @@ -2456,6 +2736,9 @@ fn disk_constraint_and_duplicate_failures_leave_retryable_inputs() { Target::GroupReceive, Target::AttachmentStage, Target::AttachmentState, + Target::DeviceControl, + Target::DeviceLink, + Target::DeviceProjection, ]; let failures = [ CommitFailure::DiskFull, @@ -2475,3 +2758,77 @@ fn disk_constraint_and_duplicate_failures_leave_retryable_inputs() { } } } + +#[test] +fn device_link_group_quota_rejects_without_publishing_partial_state() { + let mut rng = StdRng::seed_from_u64(0xa284_0000); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("group-quota-target.db"); + let target = Node::create(&path, b"target", TEST_KDF, &mut rng).unwrap(); + let before_identity = Identity::from_bytes(&target.identity.to_bytes()); + let before_state = target.device_state.clone(); + let after_identity = Identity::generate(&mut rng); + let after_state = devices::fresh_device_state(&after_identity, &mut rng).unwrap(); + let sender_chain = postcard::to_allocvec(&GroupSenderChain::generate(&mut rng)).unwrap(); + let member_identity = postcard::to_allocvec(&after_identity.public()).unwrap(); + let mut groups = Vec::with_capacity(kult_store::MAX_PROFILE_GROUPS + 1); + for index in 0..=kult_store::MAX_PROFILE_GROUPS { + let mut id = [0u8; 32]; + id[..8].copy_from_slice(&(index as u64 + 1).to_le_bytes()); + groups.push(GroupRecord { + id, + name: "bounded profile group".to_owned(), + creator: after_identity.public().ed, + members: vec![GroupMember { + peer: after_identity.public().ed, + identity: member_identity.clone(), + }], + secret: [0x61; 32], + prev_secret: None, + generation: 1, + sender_chain: sender_chain.clone(), + sent_since_rotation: 0, + pending: Vec::new(), + }); + } + + let result = target.store.commit_plan( + CommitPlan::DeviceLink(DeviceLinkPlan { + identity: IdentityTransition { + before: &before_identity, + after: &after_identity, + }, + device_state: DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }, + contacts: &[], + devices: &[], + messages: &[], + groups: &groups, + group_messages: &[], + authorities: &[], + local_metadata: &[], + notes: &[], + ephemeral: &[], + sync_events: &[], + presentation_changed: true, + }), + &mut rng, + ); + assert!(matches!(result, Err(kult_store::StoreError::GroupLimit))); + assert_eq!( + target.store.get_identity().unwrap().unwrap().public(), + before_identity.public() + ); + assert!(target.store.groups().unwrap().is_empty()); + drop(target); + + let target = Node::open(&path, b"target").unwrap(); + assert_eq!( + target.store.get_identity().unwrap().unwrap().public(), + before_identity.public() + ); + assert_eq!(target.store.get_device_state().unwrap(), Some(before_state)); + assert!(target.store.groups().unwrap().is_empty()); +} diff --git a/crates/kult-node/src/devices.rs b/crates/kult-node/src/devices.rs index fed513b..fdd2c54 100644 --- a/crates/kult-node/src/devices.rs +++ b/crates/kult-node/src/devices.rs @@ -1,6 +1,6 @@ //! C2 linked-device lifecycle and proximate state transfer. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use rand_core::CryptoRngCore; use serde::{Deserialize, Serialize}; @@ -8,18 +8,23 @@ use sha2::{Digest, Sha256}; use zeroize::Zeroizing; use kult_crypto::{ - DeviceCertificate, DeviceLinkOffer, DeviceLinkResponse, DeviceManifest, - PendingDeviceLinkSource, PendingDeviceLinkTarget, + seal_device_link_recovery_package, DeviceCertificate, DeviceLinkOffer, DeviceLinkResponse, + DeviceManifest, GroupSenderChain, PendingDeviceLinkSource, PendingDeviceLinkTarget, }; use kult_protocol::{ decode_content, resolve_device_sync_events, DecodedContent, DeviceSyncBundle, DeviceSyncEvent, DeviceSyncNamespace, }; use kult_store::{ - ContactDeviceRecord, ContactRecord, DeviceChannelRecord, DeviceStateRecord, + CapabilityDelete, CommitPlan, ContactDeviceRecord, ContactRecord, DeviceChannelRecord, + DeviceControlPlan, DeviceLinkPlan, DeviceLinkRecoveryRecord, DeviceLinkRecoveryTransition, + DeviceProjection, DeviceProjectionPlan, DeviceStateRecord, DeviceStateTransition, DeviceTransferGroup, DeviceTransferSelection, DeviceTransferSnapshot, Direction, - EphemeralRecord, GroupAuthorityRecord, GroupMessageRecord, LocalMetadataKey, - LocalMetadataRecord, MessageRecord, NoteMessageRecord, Store, THEME_PREFERENCE_KEY, + EphemeralRecord, EphemeralTransition, GroupAuthorityRecord, GroupAuthorityStateTransition, + GroupMessageDelete, GroupMessageRecord, GroupRecord, GroupStatePlan, GroupStateTransition, + GroupTransition, IdentityTransition, LocalMetadataKey, LocalMetadataRecord, MaintenancePlan, + MessageDelete, MessageRecord, NoteMessageRecord, PendingAnnounce, QueueDelete, SessionDelete, + Store, MAX_DEVICE_CONTROL_MUTATIONS, THEME_PREFERENCE_KEY, }; use crate::{ @@ -43,26 +48,21 @@ enum SyncGroupValue { Authority(GroupAuthorityRecord), } -pub(crate) fn initialize_fresh_device( - store: &Store, +pub(crate) fn fresh_device_state( account: &Identity, rng: &mut impl CryptoRngCore, -) -> kult_store::Result<()> { +) -> kult_store::Result { let device = Identity::generate(rng); let certificate = DeviceCertificate::issue(account, &device, 0, rng); let manifest = DeviceManifest::initial(account, certificate.clone(), DEFAULT_DEVICE_NAME.into(), 0)?; - store.put_device_state( - &DeviceStateRecord { - local_device_secret: device.to_bytes().to_vec(), - local_certificate: certificate, - manifest, - sync_counter: 0, - channels: Vec::new(), - }, - rng, - )?; - Ok(()) + Ok(DeviceStateRecord { + local_device_secret: device.to_bytes().to_vec(), + local_certificate: certificate, + manifest, + sync_counter: 0, + channels: Vec::new(), + }) } pub(crate) fn load_or_migrate_device( @@ -330,13 +330,30 @@ impl Node { name: &str, rng: &mut impl CryptoRngCore, ) -> Result<()> { - let mut manifest = self.device_state.manifest.clone(); - manifest + let before = self.device_state.clone(); + let mut after = before.clone(); + after + .manifest .rename_device(&self.identity, device, name.to_owned()) .map_err(|_| NodeError::UnknownLinkedDevice)?; - self.device_state.manifest = manifest; - self.store.put_device_state(&self.device_state, rng)?; - self.events.push_back(Event::DevicesChanged); + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: Some(DeviceStateTransition { + before: Some(&before), + after: &after, + }), + link_recovery: None, + groups: &[], + insert_events: &[], + delete_events: &[], + presentation_changed: true, + }), + rng, + )?; + self.before_memory_replacement()?; + self.device_state = after; + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, [Event::DevicesChanged]); Ok(()) } @@ -359,23 +376,52 @@ impl Node { .map(|event| event.counter) .max() .unwrap_or(0); - let mut manifest = self.device_state.manifest.clone(); - manifest + let before_state = self.device_state.clone(); + let mut after_state = before_state.clone(); + after_state + .manifest .revoke_device(&self.identity, device, now, cutoff) .map_err(|_| NodeError::UnknownLinkedDevice)?; - self.device_state.manifest = manifest; - self.device_state + after_state .channels .retain(|channel| &channel.peer_device != device); // Revocation always rotates this installation's sender chains. A // revoked copy can retain old ciphertext/key material, but receives // no fresh chain snapshots from the surviving channel set. - for mut group in self.store.groups()? { - self.rotate_group(&mut group, rng)?; - self.store.put_group(&group, rng)?; + let before_groups = self.store.groups()?; + let mut after_groups = before_groups.clone(); + for group in &mut after_groups { + self.rotate_group(group, rng)?; } - self.store.put_device_state(&self.device_state, rng)?; - self.events.push_back(Event::DevicesChanged); + let group_transitions = before_groups + .iter() + .zip(&after_groups) + .map(|(before, after)| GroupTransition { before, after }) + .collect::>(); + let recovery = self.store.get_device_link_recovery(device)?; + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: Some(DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }), + link_recovery: recovery + .as_ref() + .map(|before| DeviceLinkRecoveryTransition { + before: Some(before), + after: None, + }), + groups: &group_transitions, + insert_events: &[], + delete_events: &[], + presentation_changed: true, + }), + rng, + )?; + self.before_memory_replacement()?; + self.device_state = after_state; + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, [Event::DevicesChanged]); Ok(()) } @@ -434,17 +480,56 @@ impl Node { /// encrypt the selected initial state transfer. pub fn approve_device_link( &mut self, - response: &[u8], + encoded_response: &[u8], selection: DeviceLinkSelection, confirmed: bool, now: u64, rng: &mut impl CryptoRngCore, ) -> Result> { + let response_hash: [u8; 32] = Sha256::digest(encoded_response).into(); + let response = DeviceLinkResponse::decode(encoded_response)?; + if let Some(recovery) = self.store.get_device_link_recovery(&response.device.ed)? { + if !confirmed || recovery.response_hash != response_hash { + return Err(NodeError::InvalidDeviceLink); + } + self.capture_device_sync_state(rng)?; + let snapshot = self.store.export_device_transfer(DeviceTransferSelection { + contacts: recovery.contacts, + organization: recovery.organization, + history: recovery.history, + })?; + let snapshot = postcard::to_allocvec(&snapshot).map_err(|_| NodeError::CorruptState)?; + let durable_state = self + .store + .get_device_state()? + .ok_or(NodeError::CorruptState)?; + let channel = durable_state + .channels + .iter() + .find(|channel| channel.peer_device == recovery.target_device) + .ok_or(NodeError::CorruptState)?; + let package = seal_device_link_recovery_package( + &self.identity, + &durable_state.manifest, + &recovery.target_device, + &channel.root, + &snapshot, + &recovery.link_key, + rng, + )?; + self.device_state = durable_state; + self.pending_device_link_source = None; + return Ok(package); + } + self.pending_device_link_source + .as_ref() + .ok_or(NodeError::NoPendingDeviceLink)? + .validate_approval(&response, confirmed, now)?; + self.capture_device_sync_state(rng)?; let pending = self .pending_device_link_source - .take() + .as_ref() .ok_or(NodeError::NoPendingDeviceLink)?; - let response = DeviceLinkResponse::decode(response)?; let snapshot = self.store.export_device_transfer(DeviceTransferSelection { contacts: selection.contacts, organization: selection.organization, @@ -452,18 +537,46 @@ impl Node { })?; let snapshot = postcard::to_allocvec(&snapshot).map_err(|_| NodeError::CorruptState)?; let approved = pending.approve(&self.identity, &response, confirmed, now, snapshot, rng)?; - self.device_state.manifest = approved.manifest; - self.device_state.channels.push(DeviceChannelRecord { + let before = self.device_state.clone(); + let mut after = before.clone(); + after.manifest = approved.manifest; + after.channels.push(DeviceChannelRecord { peer_device: approved.target_device, root: *approved.channel_root, send_counter: 0, receive_counter: 0, }); - self.device_state - .channels - .sort_by_key(|channel| channel.peer_device); - self.store.put_device_state(&self.device_state, rng)?; - self.events.push_back(Event::DevicesChanged); + after.channels.sort_by_key(|channel| channel.peer_device); + let recovery = DeviceLinkRecoveryRecord { + target_device: approved.target_device, + response_hash, + link_key: *approved.recovery_key, + contacts: selection.contacts, + organization: selection.organization, + history: selection.history, + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: Some(DeviceStateTransition { + before: Some(&before), + after: &after, + }), + link_recovery: Some(DeviceLinkRecoveryTransition { + before: None, + after: Some(&recovery), + }), + groups: &[], + insert_events: &[], + delete_events: &[], + presentation_changed: true, + }), + rng, + )?; + self.before_memory_replacement()?; + self.device_state = after; + self.pending_device_link_source = None; + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, [Event::DevicesChanged]); Ok(approved.package) } @@ -478,7 +591,7 @@ impl Node { ) -> Result<()> { let pending = self .pending_device_link_target - .take() + .as_ref() .ok_or(NodeError::NoPendingDeviceLink)?; if !self.device_link_target_is_pristine()? { return Err(NodeError::DeviceLinkTargetNotEmpty); @@ -490,11 +603,11 @@ impl Node { if !remainder.is_empty() || completed.certificate.device != self.device_identity.public() { return Err(NodeError::InvalidDeviceLink); } - self.store.put_identity(&completed.account, rng)?; - self.store - .import_device_transfer(&snapshot, completed.account.public().ed, rng)?; - self.identity = completed.account; - self.device_state = DeviceStateRecord { + let before_identity_bytes = self.identity.to_bytes(); + let before_identity = Identity::from_bytes(&before_identity_bytes); + let before_state = self.device_state.clone(); + let after_identity = completed.account; + let after_state = DeviceStateRecord { local_device_secret: self.device_identity.to_bytes().to_vec(), local_certificate: completed.certificate, manifest: completed.manifest, @@ -506,14 +619,90 @@ impl Node { receive_counter: 0, }], }; - self.store.put_device_state(&self.device_state, rng)?; + let DeviceTransferSnapshot { + contacts, + contact_devices, + messages, + groups: transferred_groups, + group_messages, + group_authorities, + local_metadata, + note_messages, + ephemeral_tombstones, + sync_events, + } = snapshot; + let me = after_identity.public().ed; + let mut groups = Vec::with_capacity(transferred_groups.len()); + for group in transferred_groups { + let chain = GroupSenderChain::generate(rng); + let (key_id, chain_key, iteration) = chain.snapshot(); + let pending = group + .members + .iter() + .filter(|member| member.peer != me) + .map(|member| PendingAnnounce { + peer: member.peer, + key_id, + chain_key: *chain_key, + iteration, + wire_id: None, + last_sent: 0, + }) + .collect(); + groups.push(GroupRecord { + id: group.id, + name: group.name, + creator: group.creator, + members: group.members, + secret: group.secret, + prev_secret: None, + generation: group.generation, + sender_chain: postcard::to_allocvec(&chain).map_err(|_| NodeError::CorruptState)?, + sent_since_rotation: 0, + pending, + }); + } + let receipt = self.store.commit_plan( + CommitPlan::DeviceLink(DeviceLinkPlan { + identity: IdentityTransition { + before: &before_identity, + after: &after_identity, + }, + device_state: DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }, + contacts: &contacts, + devices: &contact_devices, + messages: &messages, + groups: &groups, + group_messages: &group_messages, + authorities: &group_authorities, + local_metadata: &local_metadata, + notes: ¬e_messages, + ephemeral: &ephemeral_tombstones, + sync_events: &sync_events, + presentation_changed: true, + }), + rng, + )?; + self.before_memory_replacement()?; + self.identity = after_identity; + self.device_state = after_state; + self.pending_device_link_target = None; self.sessions.clear(); self.capabilities_advertised.clear(); - self.events.push_back(Event::DeviceLinkCompleted { - account: self.identity.public().ed, - device: self.device_id(), - }); - self.events.push_back(Event::DevicesChanged); + self.after_memory_replacement()?; + self.accept_commit_receipt( + receipt, + [ + Event::DeviceLinkCompleted { + account: self.identity.public().ed, + device: self.device_id(), + }, + Event::DevicesChanged, + ], + ); Ok(()) } @@ -525,8 +714,9 @@ impl Node { ) -> Result> { self.capture_device_sync_state(rng)?; let local = self.device_id(); - let channel = self - .device_state + let before = self.device_state.clone(); + let mut after = before.clone(); + let channel = after .channels .iter_mut() .find(|channel| &channel.peer_device == peer_device) @@ -550,8 +740,26 @@ impl Node { events, rng, )?; - self.store.put_device_state(&self.device_state, rng)?; - bundle.encode().map_err(Into::into) + let encoded = bundle.encode()?; + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: Some(DeviceStateTransition { + before: Some(&before), + after: &after, + }), + link_recovery: None, + groups: &[], + insert_events: &[], + delete_events: &[], + presentation_changed: false, + }), + rng, + )?; + self.before_memory_replacement()?; + self.device_state = after; + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, []); + Ok(encoded) } /// Import one authenticated linked-device convergence bundle. Replays, @@ -595,27 +803,66 @@ impl Node { && new.revoked_at.is_some() }) }); - let mut inserted = 0usize; + let existing_events = self + .store + .device_sync_events()? + .into_iter() + .collect::>(); + let mut inserts = BTreeSet::new(); for event in opened.events { - let encoded = event.encode()?; - if self.store.put_device_sync_event(&encoded, rng)? { - inserted += 1; + let bytes = event.encode()?; + if !existing_events.contains(&bytes) { + inserts.insert(bytes); } } - self.device_state.manifest = opened.manifest; - self.device_state.channels[channel_index].receive_counter = bundle.sequence; - self.device_state.channels.retain(|channel| { - self.device_state.manifest.devices.iter().any(|entry| { + let insert_events = inserts.into_iter().collect::>(); + let inserted = insert_events.len(); + let before_state = self.device_state.clone(); + let mut after_state = before_state.clone(); + after_state.manifest = opened.manifest; + after_state.channels[channel_index].receive_counter = bundle.sequence; + after_state.channels.retain(|channel| { + after_state.manifest.devices.iter().any(|entry| { entry.certificate.device_id() == channel.peer_device && entry.revoked_at.is_none() }) }); + let before_groups = self.store.groups()?; + let mut after_groups = before_groups.clone(); if newly_revoked { - for mut group in self.store.groups()? { - self.rotate_group(&mut group, rng)?; - self.store.put_group(&group, rng)?; + for group in &mut after_groups { + self.rotate_group(group, rng)?; } } - self.store.put_device_state(&self.device_state, rng)?; + let group_transitions = before_groups + .iter() + .zip(&after_groups) + .filter(|(before, after)| before != after) + .map(|(before, after)| GroupTransition { before, after }) + .collect::>(); + let recovery = self.store.get_device_link_recovery(&bundle.sender)?; + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: Some(DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }), + link_recovery: recovery + .as_ref() + .map(|before| DeviceLinkRecoveryTransition { + before: Some(before), + after: None, + }), + groups: &group_transitions, + insert_events: &insert_events, + delete_events: &[], + presentation_changed: manifest_changed || inserted > 0, + }), + rng, + )?; + self.before_memory_replacement()?; + self.device_state = after_state; + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, []); self.apply_resolved_device_sync(rng)?; if inserted > 0 || manifest_changed { self.events.push_back(Event::DevicesChanged); @@ -742,13 +989,35 @@ impl Node { ); } - let stored = self - .store - .device_sync_events()? - .into_iter() - .map(|bytes| DeviceSyncEvent::decode(&bytes)) + let stored_encoded = self.store.device_sync_events()?; + let stored = stored_encoded + .iter() + .map(|bytes| DeviceSyncEvent::decode(bytes)) .collect::, _>>()?; let resolved = resolve_device_sync_events(&self.device_state.manifest, stored); + let winners = resolved + .values() + .map(DeviceSyncEvent::encode) + .collect::, _>>()?; + let mut redundant = stored_encoded + .into_iter() + .filter(|encoded| !winners.contains(encoded)) + .collect::>(); + while !redundant.is_empty() { + let take = redundant.len().min(MAX_DEVICE_CONTROL_MUTATIONS); + let page = redundant.drain(..take).collect::>(); + self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: None, + link_recovery: None, + groups: &[], + insert_events: &[], + delete_events: &page, + presentation_changed: false, + }), + rng, + )?; + } let mut lamport = resolved .values() .map(|event| event.lamport) @@ -768,53 +1037,146 @@ impl Node { mutations.push((*namespace, key.clone(), None)); } } - for (namespace, key, value) in mutations { - self.device_state.sync_counter = self - .device_state - .sync_counter - .checked_add(1) - .ok_or(NodeError::InvalidDeviceSync)?; - lamport = lamport.checked_add(1).ok_or(NodeError::InvalidDeviceSync)?; - let event = DeviceSyncEvent::sign( - self.identity.public().ed, - &self.device_identity, - self.device_state.sync_counter, - lamport, - self.device_state.manifest.generation, - namespace, - key, - value, + let page_size = (MAX_DEVICE_CONTROL_MUTATIONS.saturating_sub(1) / 2).max(1); + for page in mutations.chunks(page_size) { + let before_state = self.device_state.clone(); + let mut after_state = before_state.clone(); + let mut insert_events = Vec::with_capacity(page.len()); + let mut delete_events = Vec::with_capacity(page.len()); + for (namespace, key, value) in page { + if let Some(event) = resolved.get(&(*namespace, key.clone())) { + delete_events.push(event.encode()?); + } + after_state.sync_counter = after_state + .sync_counter + .checked_add(1) + .ok_or(NodeError::InvalidDeviceSync)?; + lamport = lamport.checked_add(1).ok_or(NodeError::InvalidDeviceSync)?; + let event = DeviceSyncEvent::sign( + self.identity.public().ed, + &self.device_identity, + after_state.sync_counter, + lamport, + after_state.manifest.generation, + *namespace, + key.clone(), + value.clone(), + )?; + insert_events.push(event.encode()?); + } + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: Some(DeviceStateTransition { + before: Some(&before_state), + after: &after_state, + }), + link_recovery: None, + groups: &[], + insert_events: &insert_events, + delete_events: &delete_events, + presentation_changed: false, + }), + rng, )?; - self.store.put_device_sync_event(&event.encode()?, rng)?; + self.before_memory_replacement()?; + self.device_state = after_state; + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, []); } // Retain only converged winners. This bounds replay material while // preserving every live value and tombstone needed by a new peer. - let all = self - .store - .device_sync_events()? - .into_iter() - .map(|bytes| DeviceSyncEvent::decode(&bytes)) + let all_encoded = self.store.device_sync_events()?; + let all = all_encoded + .iter() + .map(|bytes| DeviceSyncEvent::decode(bytes)) .collect::, _>>()?; let compacted = resolve_device_sync_events(&self.device_state.manifest, all) .into_values() .map(|event| event.encode()) .collect::, _>>()?; - self.store.retain_device_sync_events(&compacted)?; - self.store.put_device_state(&self.device_state, rng)?; + let compacted = compacted.into_iter().collect::>(); + let mut redundant = all_encoded + .into_iter() + .filter(|encoded| !compacted.contains(encoded)) + .collect::>(); + while !redundant.is_empty() { + let take = redundant.len().min(MAX_DEVICE_CONTROL_MUTATIONS); + let page = redundant.drain(..take).collect::>(); + self.store.commit_plan( + CommitPlan::DeviceControl(DeviceControlPlan { + state: None, + link_recovery: None, + groups: &[], + insert_events: &[], + delete_events: &page, + presentation_changed: false, + }), + rng, + )?; + } + Ok(()) + } + + fn retire_device_projection_queue( + &mut self, + queue: &[QueueDelete], + rng: &mut impl CryptoRngCore, + ) -> Result<()> { + for page in queue.chunks(kult_store::MAX_MAINTENANCE_TRANSITIONS) { + let receipt = self.store.commit_plan( + CommitPlan::Maintenance(MaintenancePlan { + seen: &[], + delete_pending: &[], + delete_queue: page, + update_queue: &[], + delete_replay: &[], + messages: &[], + deliveries: &[], + group_messages: &[], + groups: &[], + ephemeral: &[], + delete_messages: &[], + delete_group_messages: &[], + delete_media: &[], + delete_scheduled: &[], + delete_sessions: &[], + delete_capabilities: &[], + clear_reset_markers: &[], + delete_controls: &[], + acknowledge_presentation: None, + presentation_changed: false, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } Ok(()) } - fn apply_resolved_device_sync(&mut self, rng: &mut impl CryptoRngCore) -> Result<()> { + pub(crate) fn apply_resolved_device_sync( + &mut self, + rng: &mut impl CryptoRngCore, + ) -> Result<()> { let events = self .store .device_sync_events()? .into_iter() .map(|bytes| DeviceSyncEvent::decode(&bytes)) .collect::, _>>()?; - for ((namespace, key), event) in - resolve_device_sync_events(&self.device_state.manifest, events) - { + let resolved = resolve_device_sync_events(&self.device_state.manifest, events); + // Definitions own the existence of group state. Project them before + // authority winners so a newly synchronized group and its authority + // converge in this invocation rather than requiring another tick. + for ((namespace, key), event) in &resolved { + if *namespace == DeviceSyncNamespace::Groups && key.first() == Some(&b'd') { + self.apply_sync_group(key, event.value.as_deref(), rng)?; + } + } + for ((namespace, key), event) in resolved { + if namespace == DeviceSyncNamespace::Groups && key.first() == Some(&b'd') { + continue; + } match namespace { DeviceSyncNamespace::Contacts => { if key.len() == 32 { @@ -827,15 +1189,95 @@ impl Node { if contact.peer != peer { return Err(NodeError::InvalidDeviceSync); } - let verified = self - .store - .get_contact(&peer)? - .is_some_and(|stored| stored.verified); + let before = self.store.get_contact(&peer)?; + let verified = before.as_ref().is_some_and(|stored| stored.verified); contact.verified = verified; - self.store.put_contact(&contact, rng)?; + if before.as_ref() != Some(&contact) { + let projection = DeviceProjection::Contact { + before: before.as_ref(), + after: Some(&contact), + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } else { - self.store.delete_contact(&peer)?; - self.sessions.remove(&peer); + let before = self.store.get_contact(&peer)?; + let session = self.store.get_session(&peer)?; + let capabilities = self.store.get_capabilities(&peer)?; + let queue = self + .store + .queue_all()? + .into_iter() + .filter(|(_, item)| item.peer == peer) + .map(|(sequence, item)| QueueDelete { + sequence, + content_id: item.envelope.content_id(), + }) + .collect::>(); + if before.is_some() + || session.is_some() + || capabilities.is_some() + || !queue.is_empty() + { + self.retire_device_projection_queue(&queue, rng)?; + let projections = before + .as_ref() + .map(|before| { + vec![DeviceProjection::Contact { + before: Some(before), + after: None, + }] + }) + .unwrap_or_default(); + let sessions = session + .as_ref() + .map(|before| { + vec![SessionDelete { + peer_device: peer, + before, + }] + }) + .unwrap_or_default(); + let capability_deletes = capabilities + .as_ref() + .map(|before| { + vec![CapabilityDelete { + peer_device: peer, + before, + }] + }) + .unwrap_or_default(); + if projections.is_empty() + && sessions.is_empty() + && capability_deletes.is_empty() + { + continue; + } + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &projections, + delete_sessions: &sessions, + delete_capabilities: &capability_deletes, + delete_queue: &[], + presentation_changed: before.is_some(), + }), + rng, + )?; + self.before_memory_replacement()?; + self.sessions.remove(&peer); + self.capabilities_advertised.remove(&peer); + self.after_memory_replacement()?; + self.accept_commit_receipt(receipt, []); + } } } else if key.len() == 65 && key[0] == b'd' { let account: [u8; 32] = key[1..33] @@ -844,26 +1286,99 @@ impl Node { let device: [u8; 32] = key[33..65] .try_into() .map_err(|_| NodeError::InvalidDeviceSync)?; - if let Some(value) = event.value { + let before = self.store.contact_devices()?.into_iter().find(|endpoint| { + endpoint.account == account && endpoint.device == device + }); + let after = if let Some(value) = event.value { let endpoint: ContactDeviceRecord = decode_exact(&value)?; if endpoint.account != account || endpoint.device != device { return Err(NodeError::InvalidDeviceSync); } - self.store.put_contact_device(&endpoint, rng)?; - if endpoint.revoked_at.is_some() { + Some(endpoint) + } else { + None + }; + let retiring = after + .as_ref() + .is_none_or(|endpoint| endpoint.revoked_at.is_some()); + let session = if retiring { + self.store.get_session(&device)? + } else { + None + }; + let capabilities = if retiring { + self.store.get_capabilities(&device)? + } else { + None + }; + let queue = if retiring { + self.store + .queue_all()? + .into_iter() + .filter(|(_, item)| item.peer == device) + .map(|(sequence, item)| QueueDelete { + sequence, + content_id: item.envelope.content_id(), + }) + .collect::>() + } else { + Vec::new() + }; + if before.as_ref() != after.as_ref() + || session.is_some() + || capabilities.is_some() + || !queue.is_empty() + { + self.retire_device_projection_queue(&queue, rng)?; + let projections = if before.as_ref() != after.as_ref() { + vec![DeviceProjection::ContactDevice { + before: before.as_ref(), + after: after.as_ref(), + }] + } else { + Vec::new() + }; + let sessions = session + .as_ref() + .map(|before| { + vec![SessionDelete { + peer_device: device, + before, + }] + }) + .unwrap_or_default(); + let capability_deletes = capabilities + .as_ref() + .map(|before| { + vec![CapabilityDelete { + peer_device: device, + before, + }] + }) + .unwrap_or_default(); + if projections.is_empty() + && sessions.is_empty() + && capability_deletes.is_empty() + { + continue; + } + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &projections, + delete_sessions: &sessions, + delete_capabilities: &capability_deletes, + delete_queue: &[], + presentation_changed: before.as_ref() != after.as_ref(), + }), + rng, + )?; + if retiring { + self.before_memory_replacement()?; self.sessions.remove(&device); self.capabilities_advertised.remove(&device); - self.store.delete_session(&device)?; - self.store.delete_capabilities(&device)?; - self.store.queue_remove_peer(&device)?; + self.after_memory_replacement()?; } - } else { - self.sessions.remove(&device); - self.capabilities_advertised.remove(&device); - self.store.delete_session(&device)?; - self.store.delete_capabilities(&device)?; - self.store.queue_remove_peer(&device)?; - self.store.delete_contact_device(&account, &device)?; + self.accept_commit_receipt(receipt, []); } } else { return Err(NodeError::InvalidDeviceSync); @@ -875,14 +1390,32 @@ impl Node { .try_into() .map_err(|_| NodeError::InvalidDeviceSync)?; let verified = event.value.as_deref() == Some(&[1][..]); - if let Some(mut contact) = self.store.get_contact(&peer)? { + if let Some(before) = self.store.get_contact(&peer)? { + let mut contact = before.clone(); contact.verified = verified; - self.store.put_contact(&contact, rng)?; + if contact != before { + let projection = DeviceProjection::Contact { + before: Some(&before), + after: Some(&contact), + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } } DeviceSyncNamespace::LocalOrganization => { let metadata_key: LocalMetadataKey = decode_exact(&key)?; - if let Some(value) = event.value { + let before = self.store.get_local_metadata(&metadata_key)?; + let after = if let Some(value) = event.value { let record: LocalMetadataRecord = decode_exact(&value)?; if record.key() != metadata_key || matches!(record, LocalMetadataRecord::Draft(_)) @@ -894,9 +1427,26 @@ impl Node { { return Err(NodeError::InvalidDeviceSync); } - self.store.put_local_metadata(&record, rng)?; + Some(record) } else { - self.store.delete_local_metadata(&metadata_key)?; + None + }; + if before.as_ref() != after.as_ref() { + let projection = DeviceProjection::LocalMetadata { + before: before.as_ref(), + after: after.as_ref(), + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); } } DeviceSyncNamespace::ConversationHistory @@ -905,9 +1455,7 @@ impl Node { self.apply_sync_history(&key, event.value.as_deref(), rng)?; } DeviceSyncNamespace::Groups => { - if let Some(value) = event.value { - self.apply_sync_group(&key, &value, rng)?; - } + self.apply_sync_group(&key, event.value.as_deref(), rng)?; } DeviceSyncNamespace::ExpiryTombstones => { if let Some(value) = event.value { @@ -918,27 +1466,98 @@ impl Node { { return Err(NodeError::InvalidDeviceSync); } - self.store.put_ephemeral_record(&tombstone, rng)?; + let before_ephemeral = self.store.get_ephemeral_record( + &tombstone.conversation, + &tombstone.author, + &tombstone.content_id, + )?; + if before_ephemeral.as_ref().is_some_and(|before| { + before != &tombstone + && before.state != kult_store::EphemeralState::Active + }) { + return Err(NodeError::InvalidDeviceSync); + } + let mut delete_messages = Vec::new(); + let mut delete_group_messages = Vec::new(); match tombstone.conversation { kult_store::EphemeralConversation::Pairwise(peer) => { - self.store.delete_message_record( - &peer, - if tombstone.author == self.identity.public().ed { - Direction::Outbound - } else { - Direction::Inbound - }, - &tombstone.content_id, - )?; + let direction = if tombstone.author == self.identity.public().ed { + Direction::Outbound + } else { + Direction::Inbound + }; + if let Some(message) = self + .store + .messages_with(&peer)? + .into_iter() + .find(|message| { + message.direction == direction + && message.id == tombstone.content_id + }) + { + delete_messages.push(message); + } } kult_store::EphemeralConversation::Group(group) => { - self.store.delete_group_message_record( - &group, - &tombstone.author, - &tombstone.content_id, - )?; + if let Some(message) = self + .store + .group_messages(&group)? + .into_iter() + .find(|message| { + message.sender == tombstone.author + && message.id == tombstone.content_id + }) + { + delete_group_messages.push(message); + } } } + let ephemeral = (before_ephemeral.as_ref() != Some(&tombstone)) + .then_some(EphemeralTransition { + before: before_ephemeral.as_ref(), + after: &tombstone, + }) + .into_iter() + .collect::>(); + let message_deletes = delete_messages + .iter() + .map(|before| MessageDelete { before }) + .collect::>(); + let group_message_deletes = delete_group_messages + .iter() + .map(|before| GroupMessageDelete { before }) + .collect::>(); + if !ephemeral.is_empty() + || !message_deletes.is_empty() + || !group_message_deletes.is_empty() + { + let receipt = self.store.commit_plan( + CommitPlan::Maintenance(MaintenancePlan { + seen: &[], + delete_pending: &[], + delete_queue: &[], + update_queue: &[], + delete_replay: &[], + messages: &[], + deliveries: &[], + group_messages: &[], + groups: &[], + ephemeral: &ephemeral, + delete_messages: &message_deletes, + delete_group_messages: &group_message_deletes, + delete_media: &[], + delete_scheduled: &[], + delete_sessions: &[], + delete_capabilities: &[], + clear_reset_markers: &[], + delete_controls: &[], + acknowledge_presentation: None, + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } } } @@ -966,7 +1585,28 @@ impl Node { let id: [u8; 16] = key[34..] .try_into() .map_err(|_| NodeError::InvalidDeviceSync)?; - self.store.delete_message_record(&peer, direction, &id)?; + let before = self + .store + .messages_with(&peer)? + .into_iter() + .find(|message| message.direction == direction && message.id == id); + if let Some(before) = before { + let projection = DeviceProjection::Message { + before: Some(&before), + after: None, + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } Some(b'g') if key.len() == 81 => { let group: [u8; 32] = key[1..33] @@ -978,8 +1618,28 @@ impl Node { let id: [u8; 16] = key[65..] .try_into() .map_err(|_| NodeError::InvalidDeviceSync)?; - self.store - .delete_group_message_record(&group, &sender, &id)?; + let before = self + .store + .group_messages(&group)? + .into_iter() + .find(|message| message.sender == sender && message.id == id); + if let Some(before) = before { + let projection = DeviceProjection::GroupMessage { + before: Some(&before), + after: None, + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } _ => {} } @@ -1001,8 +1661,29 @@ impl Node { // A target device never inherits another device's queue/wire // promise. History delivery is account-level and immutable. message.wire_id = None; - if !self.store.update_message(&message, rng)? { - self.store.put_message(&message, rng)?; + let before = self + .store + .messages_with(&message.peer)? + .into_iter() + .find(|stored| { + stored.id == message.id && stored.direction == message.direction + }); + if before.as_ref() != Some(&message) { + let projection = DeviceProjection::Message { + before: before.as_ref(), + after: Some(&message), + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); } } SyncHistoryValue::Group(mut message) => { @@ -1015,8 +1696,27 @@ impl Node { return Err(NodeError::InvalidDeviceSync); } message.wire_body = None; - if !self.store.update_group_message(&message, rng)? { - self.store.put_group_message(&message, rng)?; + let before = self + .store + .group_messages(&message.group)? + .into_iter() + .find(|stored| stored.id == message.id && stored.sender == message.sender); + if before.as_ref() != Some(&message) { + let projection = DeviceProjection::GroupMessage { + before: before.as_ref(), + after: Some(&message), + }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); } } SyncHistoryValue::Note(message) => { @@ -1026,13 +1726,31 @@ impl Node { if expected != key { return Err(NodeError::InvalidDeviceSync); } - if !self + let before = self .store .note_messages()? .iter() - .any(|stored| stored.id == message.id) - { - self.store.put_note_message(&message, rng)?; + .find(|stored| stored.id == message.id) + .cloned(); + match before { + Some(before) if before != message => { + return Err(NodeError::InvalidDeviceSync); + } + Some(_) => {} + None => { + let projection = DeviceProjection::Note { after: &message }; + let receipt = self.store.commit_plan( + CommitPlan::DeviceProjection(DeviceProjectionPlan { + projections: &[projection], + delete_sessions: &[], + delete_capabilities: &[], + delete_queue: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } } } @@ -1042,9 +1760,85 @@ impl Node { fn apply_sync_group( &mut self, key: &[u8], - value: &[u8], + value: Option<&[u8]>, rng: &mut impl CryptoRngCore, ) -> Result<()> { + let Some(value) = value else { + if key.len() != 33 { + return Err(NodeError::InvalidDeviceSync); + } + let group: [u8; 32] = key[1..] + .try_into() + .map_err(|_| NodeError::InvalidDeviceSync)?; + match key[0] { + b'd' => { + let before = self.store.get_group(&group)?; + let chain_rows = self.store.group_chains(&group)?; + let authority = self.store.get_group_authority(&group)?; + if before.is_none() && chain_rows.is_empty() && authority.is_none() { + return Ok(()); + } + let groups = before + .as_ref() + .map(|before| GroupStateTransition { + before: Some(before), + after: None, + }) + .into_iter() + .collect::>(); + let chains = chain_rows + .iter() + .map(|(peer, chain)| kult_store::GroupChainStateTransition { + group, + peer: *peer, + before: Some(chain.as_slice()), + after: None, + }) + .collect::>(); + let authorities = authority + .as_ref() + .map(|before| GroupAuthorityStateTransition { + before: Some(before), + after: None, + }) + .into_iter() + .collect::>(); + let receipt = self.store.commit_plan( + CommitPlan::GroupState(GroupStatePlan { + groups: &groups, + chains: &chains, + contacts: &[], + authorities: &authorities, + delete_controls: &[], + presentation_changed: before.is_some(), + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } + b'a' => { + if let Some(before) = self.store.get_group_authority(&group)? { + let receipt = self.store.commit_plan( + CommitPlan::GroupState(GroupStatePlan { + groups: &[], + chains: &[], + contacts: &[], + authorities: &[GroupAuthorityStateTransition { + before: Some(&before), + after: None, + }], + delete_controls: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } + } + _ => return Err(NodeError::InvalidDeviceSync), + } + return Ok(()); + }; match decode_exact::(value)? { SyncGroupValue::Definition(group) => { let mut expected = Vec::with_capacity(33); @@ -1053,33 +1847,78 @@ impl Node { if expected != key { return Err(NodeError::InvalidDeviceSync); } - if let Some(mut stored) = self.store.get_group(&group.id)? { + if let Some(stored) = self.store.get_group(&group.id)? { if group.generation >= stored.generation { - stored.name = group.name; - stored.creator = group.creator; - stored.members = group.members; - stored.secret = group.secret; - stored.prev_secret = None; - stored.generation = group.generation; - self.store.put_group(&stored, rng)?; + let mut after = stored.clone(); + after.name = group.name; + after.creator = group.creator; + after.members = group.members; + after.secret = group.secret; + after.prev_secret = None; + after.generation = group.generation; + if after != stored { + let receipt = self.store.commit_plan( + CommitPlan::GroupState(GroupStatePlan { + groups: &[GroupStateTransition { + before: Some(&stored), + after: Some(&after), + }], + chains: &[], + contacts: &[], + authorities: &[], + delete_controls: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } } else { - self.store.import_device_transfer( - &DeviceTransferSnapshot { - contacts: Vec::new(), - contact_devices: Vec::new(), - messages: Vec::new(), - groups: vec![group], - group_messages: Vec::new(), - group_authorities: Vec::new(), - local_metadata: Vec::new(), - note_messages: Vec::new(), - ephemeral_tombstones: Vec::new(), - sync_events: Vec::new(), - }, - self.identity.public().ed, + let me = self.identity.public().ed; + let chain = GroupSenderChain::generate(rng); + let (key_id, chain_key, iteration) = chain.snapshot(); + let pending = group + .members + .iter() + .filter(|member| member.peer != me) + .map(|member| PendingAnnounce { + peer: member.peer, + key_id, + chain_key: *chain_key, + iteration, + wire_id: None, + last_sent: 0, + }) + .collect(); + let after = GroupRecord { + id: group.id, + name: group.name, + creator: group.creator, + members: group.members, + secret: group.secret, + prev_secret: None, + generation: group.generation, + sender_chain: postcard::to_allocvec(&chain) + .map_err(|_| NodeError::CorruptState)?, + sent_since_rotation: 0, + pending, + }; + let receipt = self.store.commit_plan( + CommitPlan::GroupState(GroupStatePlan { + groups: &[GroupStateTransition { + before: None, + after: Some(&after), + }], + chains: &[], + contacts: &[], + authorities: &[], + delete_controls: &[], + presentation_changed: true, + }), rng, )?; + self.accept_commit_receipt(receipt, []); } } SyncGroupValue::Authority(authority) => { @@ -1089,7 +1928,27 @@ impl Node { if expected != key { return Err(NodeError::InvalidDeviceSync); } - self.store.put_group_authority(&authority, rng)?; + if self.store.get_group(&authority.group)?.is_none() { + return Ok(()); + } + let before = self.store.get_group_authority(&authority.group)?; + if before.as_ref() != Some(&authority) { + let receipt = self.store.commit_plan( + CommitPlan::GroupState(GroupStatePlan { + groups: &[], + chains: &[], + contacts: &[], + authorities: &[GroupAuthorityStateTransition { + before: before.as_ref(), + after: Some(&authority), + }], + delete_controls: &[], + presentation_changed: true, + }), + rng, + )?; + self.accept_commit_receipt(receipt, []); + } } } Ok(()) diff --git a/crates/kult-node/src/lib.rs b/crates/kult-node/src/lib.rs index 3b96d62..bbff34b 100644 --- a/crates/kult-node/src/lib.rs +++ b/crates/kult-node/src/lib.rs @@ -657,12 +657,19 @@ impl Node { profile: KdfProfile, rng: &mut impl CryptoRngCore, ) -> Result { - let store = Store::create(path, passphrase, profile, rng)?; let identity = Identity::generate(rng); - store.put_identity(&identity, rng)?; - devices::initialize_fresh_device(&store, &identity, rng)?; + let device_state = devices::fresh_device_state(&identity, rng)?; let vault = PrekeyVault::generate(rng); - store.put_prekeys(&vault.encode(), rng)?; + let encoded_vault = vault.encode(); + let store = Store::create_profile( + path, + passphrase, + profile, + &identity, + &device_state, + &encoded_vault, + rng, + )?; Self::assemble(store, identity, vault) } @@ -697,14 +704,20 @@ impl Node { profile, rng, |store, rng| { - let identity = store - .get_identity()? - .ok_or(kult_store::StoreError::NotAStore)?; - if store.get_device_state()?.is_none() { - devices::initialize_fresh_device(store, &identity, rng)?; + if store.get_identity()?.is_none() || store.get_device_state()?.is_none() { + return Err(kult_store::StoreError::NotAStore); } let vault = PrekeyVault::generate(rng); - store.put_prekeys(&vault.encode(), rng)?; + let encoded = vault.encode(); + store.commit_plan( + CommitPlan::PrekeyPublish(PrekeyPublishPlan { + prekeys: PrekeyTransition { + before: None, + after: &encoded, + }, + }), + rng, + )?; Ok(vault) }, )?; @@ -918,7 +931,7 @@ impl Node { self.store.commit_plan( CommitPlan::PrekeyPublish(PrekeyPublishPlan { prekeys: PrekeyTransition { - before: &before_vault, + before: Some(&before_vault), after: &after_vault, }, }), @@ -2193,9 +2206,24 @@ impl Node { pub async fn tick(&mut self, now: u64, rng: &mut impl CryptoRngCore) -> Result> { self.acknowledge_presentation(rng)?; if self.device_state_dirty { - self.store.put_device_state(&self.device_state, rng)?; + let receipt = self.store.commit_plan( + CommitPlan::DeviceControl(kult_store::DeviceControlPlan { + state: Some(kult_store::DeviceStateTransition { + before: None, + after: &self.device_state, + }), + link_recovery: None, + groups: &[], + insert_events: &[], + delete_events: &[], + presentation_changed: false, + }), + rng, + )?; self.device_state_dirty = false; + self.accept_commit_receipt(receipt, []); } + self.apply_resolved_device_sync(rng)?; if !self.media_reconciled { let batch = self .store @@ -3973,7 +4001,7 @@ impl Node { events.extend(prepared.events.clone()); } let prekeys = init.opk_id.map(|_| PrekeyTransition { - before: before_vault.as_ref(), + before: Some(before_vault.as_ref()), after: after_vault.as_ref(), }); let receipt = self.store.commit_plan( diff --git a/crates/kult-node/tests/linked_devices_e2e.rs b/crates/kult-node/tests/linked_devices_e2e.rs index b09d645..71b1757 100644 --- a/crates/kult-node/tests/linked_devices_e2e.rs +++ b/crates/kult-node/tests/linked_devices_e2e.rs @@ -10,6 +10,8 @@ use kult_node::{ NodeError, }; use kult_store::MediaTransferState; +#[cfg(feature = "test-failpoints")] +use kult_store::{CommitFailpoint, CommitFailure}; use kult_transport::{DeliveryHint, SneakernetTransport}; use rand::{rngs::StdRng, SeedableRng}; use tempfile::TempDir; @@ -116,13 +118,14 @@ fn three_devices_link_transfer_converge_restart_and_revoke() { assert_eq!(tablet.peer_id(), account); assert!(tablet.contacts().unwrap().is_empty()); assert!(tablet.note_to_self_messages().unwrap().is_empty()); + let mut tablet_rng = StdRng::seed_from_u64(0x5e1e_c710); + futures::executor::block_on(tablet.tick(124, &mut tablet_rng)).unwrap(); + assert!(tablet.contacts().unwrap().is_empty()); + assert!(tablet.note_to_self_messages().unwrap().is_empty()); let source_to_laptop = source.export_device_sync(&laptop_device, &mut rng).unwrap(); - assert!( - laptop - .import_device_sync(&source_to_laptop, &mut rng) - .unwrap() - > 0 - ); + laptop + .import_device_sync(&source_to_laptop, &mut rng) + .unwrap(); assert_eq!(laptop.linked_devices().len(), 3); // Concurrent account state and history changes converge by signed event @@ -210,6 +213,65 @@ fn three_devices_link_transfer_converge_restart_and_revoke() { )); } +#[test] +fn linked_group_deletion_converges_and_survives_restart() { + let dir = TempDir::new().unwrap(); + let mut rng = StdRng::seed_from_u64(0x6d11_3700); + let (_, mut source) = create(&dir, "group-source", &mut rng); + let (target_path, mut target) = create(&dir, "group-target", &mut rng); + let initial_group = source + .create_group("Temporary linked group", &[], &mut rng) + .unwrap(); + let target_device = target.device_id(); + + link( + &mut source, + &mut target, + "Group target", + DeviceLinkSelection::default(), + 200, + &mut rng, + ); + assert!(target + .groups() + .unwrap() + .iter() + .any(|entry| entry.id == initial_group)); + + let group = source + .create_group("Post-link group", &[], &mut rng) + .unwrap(); + source + .group_upgrade_authority(&group, 205, &mut rng) + .unwrap(); + let creation = source.export_device_sync(&target_device, &mut rng).unwrap(); + assert!(target.import_device_sync(&creation, &mut rng).unwrap() > 0); + assert!(target + .groups() + .unwrap() + .iter() + .any(|entry| entry.id == group)); + assert!(target.group_authority(&group).unwrap().signed); + + source.group_leave(&initial_group, 210, &mut rng).unwrap(); + let sync = source.export_device_sync(&target_device, &mut rng).unwrap(); + assert!(target.import_device_sync(&sync, &mut rng).unwrap() > 0); + assert!(!target + .groups() + .unwrap() + .iter() + .any(|entry| entry.id == initial_group)); + drop(target); + + let target = Node::open(&target_path, b"pass").unwrap(); + assert!(!target + .groups() + .unwrap() + .iter() + .any(|entry| entry.id == initial_group)); + assert!(target.group_authority(&group).unwrap().signed); +} + #[test] fn link_requires_pristine_target_and_explicit_confirmation() { let dir = TempDir::new().unwrap(); @@ -240,6 +302,110 @@ fn link_requires_pristine_target_and_explicit_confirmation() { .is_err()); } +#[cfg(feature = "test-failpoints")] +#[test] +fn link_secrets_and_return_value_recovery_follow_the_committed_session() { + let dir = TempDir::new().unwrap(); + let mut rng = StdRng::seed_from_u64(0xc200); + let (_, mut source) = create(&dir, "retry-source", &mut rng); + let (_, mut target) = create(&dir, "retry-target", &mut rng); + let offer = source.begin_device_link(100, &mut rng).unwrap(); + let (response, target_code) = target + .accept_device_link(&offer, "Retry target", 101, &mut rng) + .unwrap(); + assert_eq!( + source.device_link_confirmation_code(&response).unwrap(), + target_code + ); + + source.arm_commit_failpoint(CommitFailpoint::BeforeCommit, CommitFailure::Interrupted); + assert!(source + .approve_device_link( + &response, + DeviceLinkSelection::default(), + true, + 102, + &mut rng, + ) + .is_err()); + assert_eq!(source.linked_devices().len(), 1); + assert!(source.drain_events().is_empty()); + let package = source + .approve_device_link( + &response, + DeviceLinkSelection::default(), + true, + 102, + &mut rng, + ) + .unwrap(); + + target.arm_commit_failpoint(CommitFailpoint::BeforeCommit, CommitFailure::Interrupted); + assert!(target + .complete_device_link(&package, true, 103, &mut rng) + .is_err()); + assert!(target.drain_events().is_empty()); + target + .complete_device_link(&package, true, 103, &mut rng) + .unwrap(); + assert_eq!(source.peer_id(), target.peer_id()); + + let (source_path, mut source) = create(&dir, "outbox-source", &mut rng); + let (_, mut target) = create(&dir, "outbox-target", &mut rng); + let source_device = source.device_id(); + let offer = source.begin_device_link(200, &mut rng).unwrap(); + let (response, _) = target + .accept_device_link(&offer, "Outbox target", 201, &mut rng) + .unwrap(); + source.arm_commit_failpoint(CommitFailpoint::AfterCommit, CommitFailure::Interrupted); + assert!(source + .approve_device_link( + &response, + DeviceLinkSelection::default(), + true, + 202, + &mut rng, + ) + .is_err()); + assert_eq!(source.linked_devices().len(), 1); + assert!(source.drain_events().is_empty()); + drop(source); + + let mut source = Node::open(&source_path, b"pass").unwrap(); + assert_eq!(source.linked_devices().len(), 2); + let recovered_package = source + .approve_device_link( + &response, + DeviceLinkSelection::default(), + true, + 203, + &mut rng, + ) + .unwrap(); + target + .complete_device_link(&recovered_package, true, 204, &mut rng) + .unwrap(); + let sync = target.export_device_sync(&source_device, &mut rng).unwrap(); + source.import_device_sync(&sync, &mut rng).unwrap(); + assert!(matches!( + source.import_device_sync(&sync, &mut rng), + Err(NodeError::InvalidDeviceSync) + )); + drop(source); + + let mut source = Node::open(&source_path, b"pass").unwrap(); + assert!(matches!( + source.approve_device_link( + &response, + DeviceLinkSelection::default(), + true, + 205, + &mut rng, + ), + Err(NodeError::NoPendingDeviceLink) + )); +} + #[test] fn backup_recovery_mints_new_device_and_never_resurrects_old_credentials() { let dir = TempDir::new().unwrap(); diff --git a/crates/kult-store/src/backup.rs b/crates/kult-store/src/backup.rs index 5e69d9e..9c434dd 100644 --- a/crates/kult-store/src/backup.rs +++ b/crates/kult-store/src/backup.rs @@ -56,10 +56,10 @@ use kult_protocol::DeviceSyncEvent; use crate::{ acquire_database_identity_lock, acquire_store_lock, decode_exact as decode_store_exact, - migration, store_v2, ContactDeviceRecord, ContactRecord, DeviceStateRecord, + migration, store_v2, CommitPlan, ContactDeviceRecord, ContactRecord, DeviceStateRecord, EphemeralConversation, EphemeralRecord, EphemeralState, GroupAuthorityRecord, GroupMember, GroupMessageRecord, GroupRecord, LocalMetadataRecord, MessageRecord, NoteMessageRecord, - PendingAnnounce, Result, Store, StoreError, + PendingAnnounce, ProfileBootstrapPlan, Result, Store, StoreError, }; /// Backup file magic: Komms recovery file, format 7 (linked-device authority). @@ -225,6 +225,64 @@ where } impl Store { + /// Create and fully initialize a new profile in a same-directory sibling, + /// then publish it at `path` with one crash-safe atomic replacement. + pub fn create_profile( + path: &Path, + passphrase: &[u8], + profile: KdfProfile, + identity: &Identity, + device_state: &DeviceStateRecord, + prekeys: &[u8], + rng: &mut impl CryptoRngCore, + ) -> Result { + let lock = acquire_store_lock(path)?; + if path.exists() { + return Err(StoreError::NotAStore); + } + let temporary = initialization_temporary_path(path)?; + if temporary.exists() { + cleanup_initialization_temporary(&temporary)?; + } + let store = match Store::create(&temporary, passphrase, profile, rng) { + Ok(store) => store, + Err(error) => { + cleanup_initialization_temporary(&temporary)?; + return Err(error); + } + }; + if let Err(error) = store.commit_plan( + CommitPlan::ProfileBootstrap(ProfileBootstrapPlan { + identity, + device_state, + prekeys, + }), + rng, + ) { + drop(store); + cleanup_initialization_temporary(&temporary)?; + return Err(error); + } + store.validate_open_state()?; + migration::sync_database_for_replacement(&store.conn)?; + drop(store); + migration::sync_file(&temporary)?; + store_v2::sync_directory(migration::parent_directory(path))?; + initialization_failpoint(1)?; + if path.exists() { + return Err(StoreError::NotAStore); + } + migration::atomic_replace(&temporary, path)?; + initialization_failpoint(2)?; + store_v2::sync_directory(migration::parent_directory(path))?; + initialization_failpoint(3)?; + let conn = Connection::open(path)?; + let database_lock = acquire_database_identity_lock(path)?; + let store = Store::open_v2_with_parts(path, passphrase, conn, database_lock, lock, false)?; + migration::cleanup_obsolete_siblings(&temporary)?; + Ok(store) + } + /// Export this store as an encrypted backup file. Returns the file /// bytes and the freshly minted 24-word mnemonic that seals them — /// show it to the user once, then drop it; it is not stored anywhere. @@ -876,6 +934,13 @@ fn restore_temporary_path(path: &Path) -> Result { Ok(path.with_file_name(temporary)) } +fn initialization_temporary_path(path: &Path) -> Result { + let name = path.file_name().ok_or(StoreError::NotAStore)?; + let mut temporary = name.to_os_string(); + temporary.push(".initialize-v1-sibling"); + Ok(path.with_file_name(temporary)) +} + fn ensure_restore_workspace(path: &Path, logical_bytes: usize, record_count: u64) -> Result<()> { let logical_bytes = u64::try_from(logical_bytes).map_err(|_| StoreError::InsufficientMigrationSpace)?; @@ -902,6 +967,14 @@ fn cleanup_restore_temporary(path: &Path) -> Result<()> { store_v2::sync_directory(migration::parent_directory(path)) } +fn cleanup_initialization_temporary(path: &Path) -> Result<()> { + if path.exists() { + fs::remove_file(path)?; + } + migration::cleanup_obsolete_siblings(path)?; + store_v2::sync_directory(migration::parent_directory(path)) +} + pub(crate) fn cleanup_completed_restore(path: &Path) -> Result<()> { let temporary = restore_temporary_path(path)?; if temporary.exists() { @@ -910,6 +983,32 @@ pub(crate) fn cleanup_completed_restore(path: &Path) -> Result<()> { migration::cleanup_obsolete_siblings(&temporary) } +pub(crate) fn cleanup_completed_initialization(path: &Path) -> Result<()> { + let temporary = initialization_temporary_path(path)?; + if temporary.exists() { + return Err(StoreError::ReplacementRecovery); + } + migration::cleanup_obsolete_siblings(&temporary) +} + +#[cfg(test)] +static INITIALIZATION_FAILPOINT: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); + +#[cfg(test)] +fn set_initialization_failpoint(phase: u8) { + INITIALIZATION_FAILPOINT.store(phase, std::sync::atomic::Ordering::SeqCst); +} + +fn initialization_failpoint(phase: u8) -> Result<()> { + #[cfg(test)] + if INITIALIZATION_FAILPOINT.load(std::sync::atomic::Ordering::SeqCst) == phase { + INITIALIZATION_FAILPOINT.store(0, std::sync::atomic::Ordering::SeqCst); + return Err(StoreError::MigrationValidation); + } + let _ = phase; + Ok(()) +} + #[cfg(test)] static RESTORE_FAILPOINT: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); @@ -1003,14 +1102,25 @@ fn restore_device_state( created_at, )? }; - store.put_device_state( - &DeviceStateRecord { - local_device_secret: device.to_bytes().to_vec(), - local_certificate: certificate, - manifest, - sync_counter: 0, - channels: Vec::new(), - }, + let state = DeviceStateRecord { + local_device_secret: device.to_bytes().to_vec(), + local_certificate: certificate, + manifest, + sync_counter: 0, + channels: Vec::new(), + }; + store.commit_plan( + CommitPlan::DeviceControl(crate::DeviceControlPlan { + state: Some(crate::DeviceStateTransition { + before: None, + after: &state, + }), + link_recovery: None, + groups: &[], + insert_events: &[], + delete_events: &[], + presentation_changed: false, + }), rng, )?; Ok(()) @@ -1029,6 +1139,64 @@ mod tests { p_cost: 1, }; + #[test] + fn profile_publication_restarts_safely_at_every_replacement_phase() { + for phase in 1..=3 { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join(format!("profile-{phase}.db")); + let mut rng = StdRng::seed_from_u64(0x6f00 + phase as u64); + let identity = Identity::generate(&mut rng); + let device = Identity::generate(&mut rng); + let certificate = DeviceCertificate::issue(&identity, &device, 0, &mut rng); + let manifest = + DeviceManifest::initial(&identity, certificate.clone(), "This device".into(), 0) + .unwrap(); + let state = DeviceStateRecord { + local_device_secret: device.to_bytes().to_vec(), + local_certificate: certificate, + manifest, + sync_counter: 0, + channels: Vec::new(), + }; + let prekeys = vec![phase; 32]; + + set_initialization_failpoint(phase); + assert!(matches!( + Store::create_profile( + &path, + b"profile-pass", + TEST_KDF, + &identity, + &state, + &prekeys, + &mut rng, + ), + Err(StoreError::MigrationValidation) + )); + let store = if phase == 1 { + Store::create_profile( + &path, + b"profile-pass", + TEST_KDF, + &identity, + &state, + &prekeys, + &mut rng, + ) + .unwrap() + } else { + Store::open(&path, b"profile-pass").unwrap() + }; + assert_eq!( + store.get_identity().unwrap().unwrap().public(), + identity.public() + ); + assert_eq!(store.get_device_state().unwrap(), Some(state)); + assert_eq!(store.get_prekeys().unwrap().unwrap().as_slice(), prekeys); + assert!(!initialization_temporary_path(&path).unwrap().exists()); + } + } + #[test] fn restore_restarts_safely_at_every_replacement_phase() { for phase in 1..=3 { diff --git a/crates/kult-store/src/commit.rs b/crates/kult-store/src/commit.rs index 84302d4..389d29c 100644 --- a/crates/kult-store/src/commit.rs +++ b/crates/kult-store/src/commit.rs @@ -6,17 +6,20 @@ use rand_core::CryptoRngCore; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -use kult_crypto::Session; +use kult_crypto::{Identity, Session}; use kult_protocol::{ - CapabilityControl, Envelope, EnvelopeKind, MAX_GROUP_ADMIN_REQUESTS, - MAX_GROUP_AUTHORITY_MEMBERS, MAX_GROUP_MEMBER_IDENTITY_LEN, MAX_GROUP_NAME_LEN, + CapabilityControl, DeviceSyncEvent, Envelope, EnvelopeKind, MAX_DEVICE_SYNC_BUNDLE_EVENTS, + MAX_GROUP_ADMIN_REQUESTS, MAX_GROUP_AUTHORITY_MEMBERS, MAX_GROUP_MEMBER_IDENTITY_LEN, + MAX_GROUP_NAME_LEN, }; use crate::{ - decode_exact, store_v2, ContactDeviceRecord, ContactRecord, DeliveryState, Direction, - EphemeralRecord, EphemeralState, GroupAuthorityRecord, GroupMessageRecord, GroupRecord, - MediaObjectRecord, MediaRecord, MediaTransferRecord, MessageDeviceDeliveryRecord, - MessageRecord, QueueItem, Result, ScheduledMessageRecord, Store, StoreError, + decode_exact, direction_code, store_v2, ContactDeviceRecord, ContactRecord, DeliveryState, + DeviceLinkRecoveryRecord, DeviceStateRecord, Direction, EphemeralRecord, EphemeralState, + GroupAuthorityRecord, GroupMessageRecord, GroupRecord, LocalMetadataRecord, MediaObjectRecord, + MediaRecord, MediaTransferRecord, MessageDeviceDeliveryRecord, MessageRecord, + NoteMessageRecord, QueueItem, Result, ScheduledMessageRecord, Store, StoreError, + MAX_DEVICE_SYNC_EVENT_BYTES, }; /// Maximum logical durable mutations accepted by one typed commit plan. @@ -37,6 +40,29 @@ pub const MAX_GROUP_STATE_MUTATIONS: usize = 256; pub const MAX_MAINTENANCE_TRANSITIONS: usize = 256; /// Maximum authenticated control records retained for post-commit work. pub const MAX_DEFERRED_CONTROLS: usize = 512; +/// Maximum exact changes in one linked-device authority or convergence transition. +pub const MAX_DEVICE_CONTROL_MUTATIONS: usize = 8_192; +/// Maximum durable groups in one profile. +/// +/// This leaves room for a full device-sync bundle, device authority, and +/// link-recovery retirement while rotating every local group sender chain in +/// the same revocation transaction. +pub const MAX_PROFILE_GROUPS: usize = + MAX_DEVICE_CONTROL_MUTATIONS - MAX_DEVICE_SYNC_BUNDLE_EVENTS - 2; +/// Maximum selected records imported by one confirmed pristine-device link. +pub const MAX_DEVICE_LINK_MUTATIONS: usize = 8_192; +/// Maximum exact durable changes projected from one accepted sync winner. +pub const MAX_DEVICE_PROJECTION_MUTATIONS: usize = 512; + +/// Complete cryptographic bootstrap of one previously unpublished profile. +pub struct ProfileBootstrapPlan<'a> { + /// Fresh account identity. + pub identity: &'a Identity, + /// Fresh local physical-device authority state. + pub device_state: &'a DeviceStateRecord, + /// Fresh encoded prekey vault. + pub prekeys: &'a [u8], +} /// Kind of authenticated pairwise control retained for idempotent follow-up. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -240,6 +266,150 @@ pub struct ContactDeviceDelete<'a> { pub before: &'a ContactDeviceRecord, } +/// Exact account-identity replacement during a confirmed device link. +pub struct IdentityTransition<'a> { + /// Identity currently stored on the pristine target. + pub before: &'a Identity, + /// Account identity authenticated by the completed link package. + pub after: &'a Identity, +} + +/// New or exact replacement of the complete local linked-device state. +pub struct DeviceStateTransition<'a> { + /// Existing state, or `None` while atomically initializing a new profile. + pub before: Option<&'a DeviceStateRecord>, + /// Detached candidate state. + pub after: &'a DeviceStateRecord, +} + +/// Creation, exact replacement, or acknowledgement of a recoverable link package. +pub struct DeviceLinkRecoveryTransition<'a> { + /// Current recovery handle, or `None` before link approval. + pub before: Option<&'a DeviceLinkRecoveryRecord>, + /// Resulting recovery handle, or `None` after authenticated target activity. + pub after: Option<&'a DeviceLinkRecoveryRecord>, +} + +/// Exact ratchet session retired by an authenticated device transition. +pub struct SessionDelete<'a> { + /// Exact physical-device route. + pub peer_device: [u8; 32], + /// Durable session expected before deletion. + pub before: &'a Session, +} + +/// Exact session-bound capability snapshot retired with its device route. +pub struct CapabilityDelete<'a> { + /// Exact physical-device route. + pub peer_device: [u8; 32], + /// Durable capability state expected before deletion. + pub before: &'a CapabilityControl, +} + +/// One exact, idempotent projection of an accepted linked-device sync winner. +pub enum DeviceProjection<'a> { + /// Contact creation, replacement, or deletion. + Contact { + /// Exact current value. + before: Option<&'a ContactRecord>, + /// Desired value. + after: Option<&'a ContactRecord>, + }, + /// Physical contact-device creation, replacement, or deletion. + ContactDevice { + /// Exact current value. + before: Option<&'a ContactDeviceRecord>, + /// Desired value. + after: Option<&'a ContactDeviceRecord>, + }, + /// Pairwise history creation, replacement, or deletion. + Message { + /// Exact current value. + before: Option<&'a MessageRecord>, + /// Desired value. + after: Option<&'a MessageRecord>, + }, + /// Group history creation, replacement, or deletion. + GroupMessage { + /// Exact current value. + before: Option<&'a GroupMessageRecord>, + /// Desired value. + after: Option<&'a GroupMessageRecord>, + }, + /// Local organization creation, replacement, or deletion. + LocalMetadata { + /// Exact current value. + before: Option<&'a LocalMetadataRecord>, + /// Desired value. + after: Option<&'a LocalMetadataRecord>, + }, + /// A new immutable note-to-self record. + Note { + /// New record, which must not already exist. + after: &'a NoteMessageRecord, + }, +} + +/// One bounded linked-device authority, counter, convergence-log, or rotation transition. +pub struct DeviceControlPlan<'a> { + /// Optional complete local authority/counter replacement. + pub state: Option>, + /// Optional committed link-package recovery outbox transition. + pub link_recovery: Option>, + /// Sender-chain rotations owned by a device revocation. + pub groups: &'a [GroupTransition<'a>], + /// New authenticated convergence events. + pub insert_events: &'a [Vec], + /// Exact redundant convergence events removed during bounded compaction. + pub delete_events: &'a [Vec], + /// Whether presentation must be recoverable after commit. + pub presentation_changed: bool, +} + +/// Complete bounded import performed by one confirmed link onto a pristine target. +pub struct DeviceLinkPlan<'a> { + /// Authenticated account replacement. + pub identity: IdentityTransition<'a>, + /// Complete target-device authority and channel state. + pub device_state: DeviceStateTransition<'a>, + /// Selected contact records. + pub contacts: &'a [ContactRecord], + /// Selected contact-device endpoints. + pub devices: &'a [ContactDeviceRecord], + /// Selected pairwise history. + pub messages: &'a [MessageRecord], + /// Regenerated local group records. + pub groups: &'a [GroupRecord], + /// Selected group history. + pub group_messages: &'a [GroupMessageRecord], + /// Selected signed group authority. + pub authorities: &'a [GroupAuthorityRecord], + /// Selected local organization state. + pub local_metadata: &'a [LocalMetadataRecord], + /// Selected note-to-self history. + pub notes: &'a [NoteMessageRecord], + /// Terminal ephemeral tombstones. + pub ephemeral: &'a [EphemeralRecord], + /// Authenticated convergence events. + pub sync_events: &'a [Vec], + /// Whether presentation must be recoverable after commit. + pub presentation_changed: bool, +} + +/// One bounded projection from already-durable device-sync control state. +pub struct DeviceProjectionPlan<'a> { + /// Exact row projections owned by one resolved sync event. + pub projections: &'a [DeviceProjection<'a>], + /// Exact ratchet sessions retired by a revoked endpoint. + pub delete_sessions: &'a [SessionDelete<'a>], + /// Exact capability snapshots retired with those sessions. + pub delete_capabilities: &'a [CapabilityDelete<'a>], + /// Exact queued ciphertext rows invalidated by the endpoint transition. + pub delete_queue: &'a [QueueDelete], + /// Whether presentation must be recoverable after commit. + pub presentation_changed: bool, +} + /// Complete durable consequences of one pairwise send. pub struct PairwiseSendPlan<'a> { /// Every detached sending-session candidate advanced by the fan-out. @@ -409,8 +579,9 @@ pub struct PairwiseReceivePlan<'a> { /// Exact prekey-vault replacement performed by issuance or an inbound handshake. pub struct PrekeyTransition<'a> { - /// Encoded durable vault expected before the transaction. - pub before: &'a [u8], + /// Encoded durable vault expected before the transaction, or `None` + /// while completing a recovered profile. + pub before: Option<&'a [u8]>, /// Encoded candidate vault after one-time-prekey issuance or consumption. pub after: &'a [u8], } @@ -541,6 +712,8 @@ pub struct MaintenancePlan<'a> { /// The only protocol-state transaction variants exposed by the store. pub enum CommitPlan<'a> { + /// Initial account, device, and prekey publication. + ProfileBootstrap(ProfileBootstrapPlan<'a>), /// Prekey bundle issuance. PrekeyPublish(PrekeyPublishPlan<'a>), /// Pairwise send. @@ -557,6 +730,12 @@ pub enum CommitPlan<'a> { AttachmentState(AttachmentStatePlan<'a>), /// Group membership, authority, chain, or deferred-control transition. GroupState(GroupStatePlan<'a>), + /// Linked-device authority, channel counter, convergence-log, or rotation transition. + DeviceControl(DeviceControlPlan<'a>), + /// Confirmed bounded link import onto a pristine target. + DeviceLink(DeviceLinkPlan<'a>), + /// One idempotent projection from already-durable device-sync state. + DeviceProjection(DeviceProjectionPlan<'a>), /// Handshake receive. HandshakeReceive(HandshakeReceivePlan<'a>), /// Receipt or authenticated pairwise control receive. @@ -640,6 +819,7 @@ impl Store { ) -> Result { self.validate_commit_plan(&plan)?; let presentation_changed = match &plan { + CommitPlan::ProfileBootstrap(_) => false, CommitPlan::PrekeyPublish(_) => false, CommitPlan::PairwiseSend(plan) => plan.presentation_changed, CommitPlan::PairwiseReceive(plan) => plan.presentation_changed, @@ -648,6 +828,9 @@ impl Store { CommitPlan::AttachmentStage(plan) => plan.presentation_changed, CommitPlan::AttachmentState(plan) => plan.presentation_changed, CommitPlan::GroupState(plan) => plan.presentation_changed, + CommitPlan::DeviceControl(plan) => plan.presentation_changed, + CommitPlan::DeviceLink(plan) => plan.presentation_changed, + CommitPlan::DeviceProjection(plan) => plan.presentation_changed, CommitPlan::HandshakeReceive(plan) => plan.presentation_changed, CommitPlan::ReceiptReceive(plan) => plan.presentation_changed, CommitPlan::Maintenance(plan) => plan.presentation_changed, @@ -671,6 +854,7 @@ impl Store { .store .check_commit_failpoint(CommitPoint::AfterBegin)?; match plan { + CommitPlan::ProfileBootstrap(plan) => writer.profile_bootstrap(&plan)?, CommitPlan::PrekeyPublish(plan) => writer.prekey_publish(&plan)?, CommitPlan::PairwiseSend(plan) => writer.pairwise_send(&plan)?, CommitPlan::PairwiseReceive(plan) => writer.pairwise_receive(&plan)?, @@ -679,6 +863,9 @@ impl Store { CommitPlan::AttachmentStage(plan) => writer.attachment_stage(&plan)?, CommitPlan::AttachmentState(plan) => writer.attachment_state(&plan)?, CommitPlan::GroupState(plan) => writer.group_state(&plan)?, + CommitPlan::DeviceControl(plan) => writer.device_control(&plan)?, + CommitPlan::DeviceLink(plan) => writer.device_link(&plan)?, + CommitPlan::DeviceProjection(plan) => writer.device_projection(&plan)?, CommitPlan::HandshakeReceive(plan) => writer.handshake_receive(&plan)?, CommitPlan::ReceiptReceive(plan) => writer.receipt_receive(&plan)?, CommitPlan::Maintenance(plan) => writer.maintenance(&plan)?, @@ -759,6 +946,7 @@ impl Store { fn validate_commit_plan(&self, plan: &CommitPlan<'_>) -> Result<()> { match plan { + CommitPlan::ProfileBootstrap(plan) => self.validate_profile_bootstrap(plan), CommitPlan::PrekeyPublish(plan) => self.validate_prekey_publish(plan), CommitPlan::PairwiseSend(plan) => self.validate_pairwise_send(plan), CommitPlan::PairwiseReceive(plan) => self.validate_pairwise_receive(plan), @@ -767,18 +955,32 @@ impl Store { CommitPlan::AttachmentStage(plan) => self.validate_attachment_stage(plan), CommitPlan::AttachmentState(plan) => self.validate_attachment_state(plan), CommitPlan::GroupState(plan) => self.validate_group_state(plan), + CommitPlan::DeviceControl(plan) => self.validate_device_control(plan), + CommitPlan::DeviceLink(plan) => self.validate_device_link(plan), + CommitPlan::DeviceProjection(plan) => self.validate_device_projection(plan), CommitPlan::HandshakeReceive(plan) => self.validate_handshake_receive(plan), CommitPlan::ReceiptReceive(plan) => self.validate_receipt_receive(plan), CommitPlan::Maintenance(plan) => self.validate_maintenance(plan), } } + fn validate_profile_bootstrap(&self, plan: &ProfileBootstrapPlan<'_>) -> Result<()> { + if plan.prekeys.is_empty() + || self.get_identity()?.is_some() + || self.get_device_state()?.is_some() + || self.get_prekeys()?.is_some() + { + return Err(StoreError::InvalidTransition); + } + plan.identity.public().verify()?; + plan.device_state.validate(plan.identity) + } + fn validate_prekey_publish(&self, plan: &PrekeyPublishPlan<'_>) -> Result<()> { - if plan.prekeys.before.is_empty() + if plan.prekeys.before.is_some_and(<[u8]>::is_empty) || plan.prekeys.after.is_empty() - || plan.prekeys.before == plan.prekeys.after - || self.get_prekeys()?.as_ref().map(|value| value.as_slice()) - != Some(plan.prekeys.before) + || plan.prekeys.before == Some(plan.prekeys.after) + || self.get_prekeys()?.as_ref().map(|value| value.as_slice()) != plan.prekeys.before { return Err(StoreError::InvalidTransition); } @@ -1627,6 +1829,24 @@ impl Store { return Err(StoreError::InvalidTransition); } } + let current_groups = self.count_rows::()?; + let additions = plan + .groups + .iter() + .filter(|transition| transition.before.is_none() && transition.after.is_some()) + .count() as u64; + let removals = plan + .groups + .iter() + .filter(|transition| transition.before.is_some() && transition.after.is_none()) + .count() as u64; + let resulting_groups = current_groups + .checked_add(additions) + .and_then(|count| count.checked_sub(removals)) + .ok_or(StoreError::InvalidTransition)?; + if resulting_groups > MAX_PROFILE_GROUPS as u64 { + return Err(StoreError::GroupLimit); + } let mut chain_ids = HashSet::new(); for transition in plan.chains { @@ -1781,6 +2001,430 @@ impl Store { Ok(()) } + fn validate_device_control(&self, plan: &DeviceControlPlan<'_>) -> Result<()> { + let count = mutation_count([ + usize::from(plan.state.is_some()), + usize::from(plan.link_recovery.is_some()), + plan.groups.len(), + plan.insert_events.len(), + plan.delete_events.len(), + ])?; + if count == 0 || count > MAX_DEVICE_CONTROL_MUTATIONS { + return Err(StoreError::MaintenanceBounds); + } + if let Some(state) = &plan.state { + let current = self.get_device_state()?; + if current.as_ref() != state.before || state.before == Some(state.after) { + return Err(StoreError::InvalidTransition); + } + let identity = self.get_identity()?.ok_or(StoreError::InvalidTransition)?; + state.after.validate(&identity)?; + } + let mut group_ids = HashSet::new(); + for transition in plan.groups { + if !group_ids.insert(transition.after.id) { + return Err(StoreError::InvalidTransition); + } + self.validate_group_transition(transition)?; + } + + let current_events = self.device_sync_events()?; + let current = current_events + .iter() + .map(Vec::as_slice) + .collect::>(); + let inserts = plan + .insert_events + .iter() + .map(Vec::as_slice) + .collect::>(); + let deletes = plan + .delete_events + .iter() + .map(Vec::as_slice) + .collect::>(); + if inserts.len() != plan.insert_events.len() + || deletes.len() != plan.delete_events.len() + || inserts + .iter() + .any(|event| current.contains(event) || deletes.contains(event)) + || deletes.iter().any(|event| !current.contains(event)) + { + return Err(StoreError::InvalidTransition); + } + let manifest = if let Some(state) = &plan.state { + &state.after.manifest + } else { + &self + .get_device_state()? + .ok_or(StoreError::InvalidTransition)? + .manifest + }; + if let Some(transition) = &plan.link_recovery { + let target = transition + .before + .map(|record| record.target_device) + .or_else(|| transition.after.map(|record| record.target_device)) + .ok_or(StoreError::InvalidTransition)?; + if transition.before == transition.after + || transition + .before + .is_some_and(|record| record.target_device != target) + || transition + .after + .is_some_and(|record| record.target_device != target) + || self.get_device_link_recovery(&target)?.as_ref() != transition.before + { + return Err(StoreError::InvalidTransition); + } + if let Some(recovery) = transition.after { + crate::devices::validate_device_link_recovery(recovery)?; + let channel_exists = if let Some(state) = &plan.state { + state + .after + .channels + .iter() + .any(|channel| channel.peer_device == target) + } else { + self.get_device_state()?.is_some_and(|state| { + state + .channels + .iter() + .any(|channel| channel.peer_device == target) + }) + }; + if !manifest.devices.iter().any(|entry| { + entry.certificate.device_id() == target && entry.revoked_at.is_none() + }) || !channel_exists + { + return Err(StoreError::InvalidTransition); + } + } + } + for encoded in plan.insert_events { + if encoded.is_empty() || encoded.len() > MAX_DEVICE_SYNC_EVENT_BYTES { + return Err(StoreError::RecordBounds); + } + DeviceSyncEvent::decode(encoded)?.verify(manifest)?; + } + Ok(()) + } + + fn validate_device_link(&self, plan: &DeviceLinkPlan<'_>) -> Result<()> { + let count = mutation_count([ + 2, + plan.contacts.len(), + plan.devices.len(), + plan.messages.len(), + plan.groups.len(), + plan.group_messages.len(), + plan.authorities.len(), + plan.local_metadata.len(), + plan.notes.len(), + plan.ephemeral.len(), + plan.sync_events.len(), + ])?; + if plan.groups.len() > MAX_PROFILE_GROUPS { + return Err(StoreError::GroupLimit); + } + if count > MAX_DEVICE_LINK_MUTATIONS + || identity_eq(plan.identity.before, plan.identity.after) + || self + .get_identity()? + .as_ref() + .is_none_or(|current| !identity_eq(current, plan.identity.before)) + || self.get_device_state()?.as_ref() != plan.device_state.before + || plan.device_state.before == Some(plan.device_state.after) + { + return Err(StoreError::InvalidTransition); + } + plan.device_state.after.validate(plan.identity.after)?; + + let pristine_tables = [ + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + self.count_rows::()?, + ]; + if self.get_prekeys()?.is_none() || pristine_tables.into_iter().any(|count| count != 0) { + return Err(StoreError::InvalidTransition); + } + + let contact_ids = plan + .contacts + .iter() + .map(|record| record.peer) + .collect::>(); + let device_ids = plan + .devices + .iter() + .map(|record| (record.account, record.device)) + .collect::>(); + let message_ids = plan + .messages + .iter() + .map(|record| record.id) + .collect::>(); + let group_ids = plan + .groups + .iter() + .map(|record| record.id) + .collect::>(); + let group_message_ids = plan + .group_messages + .iter() + .map(|record| record.id) + .collect::>(); + let authority_ids = plan + .authorities + .iter() + .map(|record| record.group) + .collect::>(); + let note_ids = plan + .notes + .iter() + .map(|record| record.id) + .collect::>(); + if contact_ids.len() != plan.contacts.len() + || device_ids.len() != plan.devices.len() + || message_ids.len() != plan.messages.len() + || group_ids.len() != plan.groups.len() + || group_message_ids.len() != plan.group_messages.len() + || authority_ids.len() != plan.authorities.len() + || note_ids.len() != plan.notes.len() + || plan + .devices + .iter() + .any(|record| !contact_ids.contains(&record.account)) + || plan.groups.iter().any(|record| !valid_group_record(record)) + || plan + .group_messages + .iter() + .any(|record| !group_ids.contains(&record.group)) + || plan.authorities.iter().any(|record| { + !group_ids.contains(&record.group) || !valid_group_authority_record(record) + }) + || plan.ephemeral.iter().any(|record| { + record.state == EphemeralState::Active || !record.transfer_ids.is_empty() + }) + { + return Err(StoreError::InvalidTransition); + } + for record in plan.devices { + crate::devices::validate_contact_device(record)?; + } + for record in plan.local_metadata { + record.validate()?; + } + for record in plan.notes { + record.validate()?; + } + let event_ids = plan + .sync_events + .iter() + .map(Vec::as_slice) + .collect::>(); + if event_ids.len() != plan.sync_events.len() { + return Err(StoreError::InvalidTransition); + } + for encoded in plan.sync_events { + if encoded.is_empty() || encoded.len() > MAX_DEVICE_SYNC_EVENT_BYTES { + return Err(StoreError::RecordBounds); + } + DeviceSyncEvent::decode(encoded)?.verify(&plan.device_state.after.manifest)?; + } + Ok(()) + } + + fn validate_device_projection(&self, plan: &DeviceProjectionPlan<'_>) -> Result<()> { + let count = mutation_count([ + plan.projections.len(), + plan.delete_sessions.len(), + plan.delete_capabilities.len(), + plan.delete_queue.len(), + ])?; + if count == 0 || count > MAX_DEVICE_PROJECTION_MUTATIONS { + return Err(StoreError::MaintenanceBounds); + } + let mut keys = HashSet::new(); + for projection in plan.projections { + match projection { + DeviceProjection::Contact { before, after } => { + let peer = before + .map(|record| record.peer) + .or_else(|| after.map(|record| record.peer)) + .ok_or(StoreError::InvalidTransition)?; + if before == after + || before.is_some_and(|record| record.peer != peer) + || after.is_some_and(|record| record.peer != peer) + || !keys.insert((0u8, peer.to_vec())) + || self.get_contact(&peer)?.as_ref() != *before + { + return Err(StoreError::InvalidTransition); + } + } + DeviceProjection::ContactDevice { before, after } => { + let key = before + .map(|record| (record.account, record.device)) + .or_else(|| after.map(|record| (record.account, record.device))) + .ok_or(StoreError::InvalidTransition)?; + let current = self + .contact_devices()? + .into_iter() + .find(|record| (record.account, record.device) == key); + if before == after + || before.is_some_and(|record| (record.account, record.device) != key) + || after.is_some_and(|record| (record.account, record.device) != key) + || !keys.insert((1u8, [key.0.as_slice(), key.1.as_slice()].concat())) + || current.as_ref() != *before + { + return Err(StoreError::InvalidTransition); + } + if let Some(record) = after { + crate::devices::validate_contact_device(record)?; + } + } + DeviceProjection::Message { before, after } => { + let record = (*before).or(*after).ok_or(StoreError::InvalidTransition)?; + let key = (record.peer, record.direction, record.id); + let current = self + .messages_with(&record.peer)? + .into_iter() + .find(|candidate| { + (candidate.peer, candidate.direction, candidate.id) == key + }); + if before == after + || before.is_some_and(|candidate| { + (candidate.peer, candidate.direction, candidate.id) != key + }) + || after.is_some_and(|candidate| { + (candidate.peer, candidate.direction, candidate.id) != key + }) + || !keys.insert(( + 2u8, + [key.0.as_slice(), &[direction_code(key.1)], key.2.as_slice()].concat(), + )) + || current.as_ref() != *before + { + return Err(StoreError::InvalidTransition); + } + } + DeviceProjection::GroupMessage { before, after } => { + let record = (*before).or(*after).ok_or(StoreError::InvalidTransition)?; + let key = (record.group, record.sender, record.id); + let current = self + .group_messages(&record.group)? + .into_iter() + .find(|candidate| (candidate.group, candidate.sender, candidate.id) == key); + if before == after + || before.is_some_and(|candidate| { + (candidate.group, candidate.sender, candidate.id) != key + }) + || after.is_some_and(|candidate| { + (candidate.group, candidate.sender, candidate.id) != key + }) + || !keys.insert(( + 3u8, + [key.0.as_slice(), key.1.as_slice(), key.2.as_slice()].concat(), + )) + || current.as_ref() != *before + { + return Err(StoreError::InvalidTransition); + } + } + DeviceProjection::LocalMetadata { before, after } => { + let key = before + .map(|record| record.key()) + .or_else(|| after.map(LocalMetadataRecord::key)) + .ok_or(StoreError::InvalidTransition)?; + if before == after + || before.is_some_and(|record| record.key() != key) + || after.is_some_and(|record| record.key() != key) + || !keys.insert(( + 4u8, + postcard::to_allocvec(&key).map_err(|_| StoreError::Serialization)?, + )) + || self.get_local_metadata(&key)?.as_ref() != *before + { + return Err(StoreError::InvalidTransition); + } + if let Some(record) = after { + record.validate()?; + } + } + DeviceProjection::Note { after } => { + if !keys.insert((5u8, after.id.to_vec())) + || self + .note_messages()? + .iter() + .any(|record| record.id == after.id) + { + return Err(StoreError::InvalidTransition); + } + after.validate()?; + } + } + } + let mut sessions = HashSet::new(); + for delete in plan.delete_sessions { + if !sessions.insert(delete.peer_device) + || !session_eq( + self.get_session(&delete.peer_device)?.as_ref(), + Some(delete.before), + )? + { + return Err(StoreError::InvalidTransition); + } + } + let mut capabilities = HashSet::new(); + for delete in plan.delete_capabilities { + if !capabilities.insert(delete.peer_device) + || self + .get_capabilities(&delete.peer_device)? + .as_ref() + .map(CapabilityControl::encode) + .transpose()? + .as_deref() + != Some(delete.before.encode()?.as_slice()) + { + return Err(StoreError::InvalidTransition); + } + } + let queue_ids = plan + .delete_queue + .iter() + .map(|delete| delete.sequence) + .collect::>(); + if queue_ids.len() != plan.delete_queue.len() { + return Err(StoreError::InvalidTransition); + } + for delete in plan.delete_queue { + self.validate_queue_delete(*delete)?; + } + Ok(()) + } + fn validate_handshake_receive(&self, plan: &HandshakeReceivePlan<'_>) -> Result<()> { self.validate_session_transition(&plan.session)?; if plan.content_id == [0u8; 16] @@ -1854,8 +2498,9 @@ impl Store { } if let Some(prekeys) = &plan.prekeys { let current = self.get_prekeys()?; - if prekeys.before == prekeys.after - || current.as_ref().map(|value| value.as_slice()) != Some(prekeys.before) + if prekeys.before.is_none() + || prekeys.before == Some(prekeys.after) + || current.as_ref().map(|value| value.as_slice()) != prekeys.before { return Err(StoreError::InvalidTransition); } @@ -2466,6 +3111,12 @@ impl CommitWriter<'_, R> { Ok(()) } + fn profile_bootstrap(&mut self, plan: &ProfileBootstrapPlan<'_>) -> Result<()> { + self.write(|store, rng| store.put_identity(plan.identity, rng))?; + self.write(|store, rng| store.put_device_state(plan.device_state, rng))?; + self.write(|store, rng| store.put_prekeys(plan.prekeys, rng)) + } + fn prekey_publish(&mut self, plan: &PrekeyPublishPlan<'_>) -> Result<()> { self.write(|store, rng| store.put_prekeys(plan.prekeys.after, rng)) } @@ -2778,6 +3429,233 @@ impl CommitWriter<'_, R> { Ok(()) } + fn device_control(&mut self, plan: &DeviceControlPlan<'_>) -> Result<()> { + if let Some(state) = &plan.state { + self.write(|store, rng| store.put_device_state(state.after, rng))?; + } + if let Some(recovery) = &plan.link_recovery { + if let Some(after) = recovery.after { + self.write(|store, rng| store.put_device_link_recovery(after, rng))?; + } else { + let target = recovery + .before + .expect("validated recovery deletion") + .target_device; + self.write(|store, _| { + if store.delete_equality::( + &store_v2::AccountKey::new(target), + )? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + } + for transition in plan.groups { + self.write(|store, rng| store.put_group(transition.after, rng))?; + } + for encoded in plan.delete_events { + self.write(|store, _| { + if store.delete_device_sync_event(encoded)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + for encoded in plan.insert_events { + self.write(|store, rng| { + if store.put_device_sync_event(encoded, rng)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + Ok(()) + } + + fn device_link(&mut self, plan: &DeviceLinkPlan<'_>) -> Result<()> { + self.write(|store, rng| store.put_identity(plan.identity.after, rng))?; + self.write(|store, rng| store.put_device_state(plan.device_state.after, rng))?; + for contact in plan.contacts { + self.write(|store, rng| store.put_contact(contact, rng))?; + } + for device in plan.devices { + self.write(|store, rng| store.put_contact_device(device, rng))?; + } + for message in plan.messages { + self.write(|store, rng| store.put_message(message, rng))?; + self.records.messages.push(message.id); + } + for group in plan.groups { + self.write(|store, rng| store.put_group(group, rng))?; + } + for message in plan.group_messages { + self.write(|store, rng| store.put_group_message(message, rng))?; + } + for authority in plan.authorities { + self.write(|store, rng| store.put_group_authority(authority, rng))?; + } + for record in plan.local_metadata { + self.write(|store, rng| store.put_local_metadata(record, rng))?; + } + for note in plan.notes { + self.write(|store, rng| store.put_note_message(note, rng))?; + } + for record in plan.ephemeral { + self.write(|store, rng| store.put_ephemeral_record(record, rng))?; + } + for encoded in plan.sync_events { + self.write(|store, rng| { + if store.put_device_sync_event(encoded, rng)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + Ok(()) + } + + fn device_projection(&mut self, plan: &DeviceProjectionPlan<'_>) -> Result<()> { + for projection in plan.projections { + match projection { + DeviceProjection::Contact { before, after } => { + if let Some(record) = after { + self.write(|store, rng| store.put_contact(record, rng))?; + } else { + let peer = before.expect("validated contact deletion").peer; + self.write(|store, _| { + if store.delete_contact(&peer)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + } + DeviceProjection::ContactDevice { before, after } => { + if let Some(record) = after { + self.write(|store, rng| store.put_contact_device(record, rng))?; + } else { + let record = before.expect("validated device deletion"); + self.write(|store, _| { + store.delete_contact_device(&record.account, &record.device) + })?; + } + } + DeviceProjection::Message { before, after } => { + if let Some(record) = after { + if before.is_some() { + self.write(|store, rng| { + if store.update_message(record, rng)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } else { + self.write(|store, rng| store.put_message(record, rng))?; + } + self.records.messages.push(record.id); + } else { + let record = before.expect("validated message deletion"); + self.write(|store, _| { + if store.delete_message_record( + &record.peer, + record.direction, + &record.id, + )? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + } + DeviceProjection::GroupMessage { before, after } => { + if let Some(record) = after { + if before.is_some() { + self.write(|store, rng| { + if store.update_group_message(record, rng)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } else { + self.write(|store, rng| store.put_group_message(record, rng))?; + } + } else { + let record = before.expect("validated group-message deletion"); + self.write(|store, _| { + if store.delete_group_message_record( + &record.group, + &record.sender, + &record.id, + )? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + } + DeviceProjection::LocalMetadata { before, after } => { + if let Some(record) = after { + self.write(|store, rng| store.put_local_metadata(record, rng))?; + } else { + let key = before.expect("validated metadata deletion").key(); + self.write(|store, _| { + if store.delete_local_metadata(&key)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + } + DeviceProjection::Note { after } => { + self.write(|store, rng| store.put_note_message(after, rng))?; + } + } + } + for delete in plan.delete_sessions { + self.write(|store, _| { + if store.delete_equality::(&store_v2::AccountKey::new( + delete.peer_device, + ))? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + for delete in plan.delete_capabilities { + self.write(|store, _| { + if store.delete_equality::(&store_v2::AccountKey::new( + delete.peer_device, + ))? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + for delete in plan.delete_queue { + self.write(|store, _| { + if store.delete_rowid::(delete.sequence)? { + Ok(()) + } else { + Err(StoreError::InvalidTransition) + } + })?; + } + Ok(()) + } + fn handshake_receive(&mut self, plan: &HandshakeReceivePlan<'_>) -> Result<()> { if let Some(prekeys) = &plan.prekeys { self.write(|store, rng| store.put_prekeys(prekeys.after, rng))?; @@ -3122,6 +4000,10 @@ fn session_eq(left: Option<&Session>, right: Option<&Session>) -> Result { } } +fn identity_eq(left: &Identity, right: &Identity) -> bool { + left.to_bytes().as_slice() == right.to_bytes().as_slice() +} + fn valid_group_record(group: &GroupRecord) -> bool { let member_ids = group .members diff --git a/crates/kult-store/src/devices.rs b/crates/kult-store/src/devices.rs index 223ca08..68eb7e3 100644 --- a/crates/kult-store/src/devices.rs +++ b/crates/kult-store/src/devices.rs @@ -4,19 +4,17 @@ //! tables expose only row counts and approximate sealed sizes to a copied //! database, matching the rest of the store's local-metadata boundary. -use std::collections::HashSet; - use rand_core::CryptoRngCore; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -use kult_crypto::{DeviceCertificate, DeviceManifest, GroupSenderChain, Identity}; +use kult_crypto::{DeviceCertificate, DeviceManifest, Identity, MAX_LINKED_DEVICES}; +use kult_protocol::{DeviceSyncEvent, DeviceSyncNamespace}; use crate::{ decode_exact, store_v2, ContactRecord, DeliveryState, EphemeralRecord, EphemeralState, - GroupAuthorityRecord, GroupMember, GroupMessageRecord, GroupRecord, LocalMetadataRecord, - MessageRecord, NoteMessageRecord, PendingAnnounce, Result, Store, StoreError, - THEME_PREFERENCE_KEY, + GroupAuthorityRecord, GroupMember, GroupMessageRecord, LocalMetadataRecord, MessageRecord, + NoteMessageRecord, Result, Store, StoreError, THEME_PREFERENCE_KEY, }; const DEVICE_SYNC_DIGEST_DOMAIN: &[u8] = b"Komms-Store-Device-Sync-Digest-v2"; @@ -101,6 +99,24 @@ pub struct DeviceChannelRecord { pub receive_counter: u64, } +/// Small durable recovery handle for a committed link package whose caller +/// may not have observed the return value before restart. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeviceLinkRecoveryRecord { + /// Exact target physical-device id. + pub target_device: [u8; 32], + /// Digest of the signed target response accepted by the source. + pub response_hash: [u8; 32], + /// Link-package AEAD key derived from the confirmed ceremony transcript. + pub link_key: [u8; 32], + /// Whether a retry includes contacts. + pub contacts: bool, + /// Whether a retry includes shared organization state. + pub organization: bool, + /// Whether a retry includes history. + pub history: bool, +} + /// One contact account's independently addressable physical-device endpoint. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ContactDeviceRecord { @@ -160,7 +176,7 @@ pub struct DeviceStateRecord { } impl DeviceStateRecord { - fn validate(&self, account: &Identity) -> Result<()> { + pub(crate) fn validate(&self, account: &Identity) -> Result<()> { self.manifest.verify()?; self.local_certificate.verify()?; let device_bytes: Zeroizing<[u8; 64]> = Zeroizing::new( @@ -250,6 +266,28 @@ impl Store { } else { Vec::new() }; + let sync_events = self + .device_sync_events()? + .into_iter() + .map(|encoded| { + let event = DeviceSyncEvent::decode(&encoded)?; + let selected = match event.namespace { + DeviceSyncNamespace::Contacts | DeviceSyncNamespace::Verification => { + selection.contacts + } + DeviceSyncNamespace::LocalOrganization => selection.organization, + DeviceSyncNamespace::ConversationHistory + | DeviceSyncNamespace::MessageEdits + | DeviceSyncNamespace::GroupPolls => selection.history, + DeviceSyncNamespace::Groups => selection.history || selection.organization, + DeviceSyncNamespace::ExpiryTombstones => true, + }; + Ok(selected.then_some(encoded)) + }) + .collect::>>()? + .into_iter() + .flatten() + .collect(); Ok(DeviceTransferSnapshot { contacts: if selection.contacts { self.contacts()? @@ -294,87 +332,10 @@ impl Store { Vec::new() }, ephemeral_tombstones: terminal, - sync_events: self.device_sync_events()?, + sync_events, }) } - /// Import one authenticated link snapshot into a new/pristine target. - /// Group sending/receiving chains are regenerated rather than copied. - pub fn import_device_transfer( - &self, - snapshot: &DeviceTransferSnapshot, - me: [u8; 32], - rng: &mut impl CryptoRngCore, - ) -> Result<()> { - for contact in &snapshot.contacts { - self.put_contact(contact, rng)?; - } - for endpoint in &snapshot.contact_devices { - self.put_contact_device(endpoint, rng)?; - } - for message in &snapshot.messages { - self.put_message(message, rng)?; - } - for group in &snapshot.groups { - let chain = GroupSenderChain::generate(rng); - let (key_id, chain_key, iteration) = chain.snapshot(); - let pending = group - .members - .iter() - .filter(|member| member.peer != me) - .map(|member| PendingAnnounce { - peer: member.peer, - key_id, - chain_key: *chain_key, - iteration, - wire_id: None, - last_sent: 0, - }) - .collect(); - self.put_group( - &GroupRecord { - id: group.id, - name: group.name.clone(), - creator: group.creator, - members: group.members.clone(), - secret: group.secret, - prev_secret: None, - generation: group.generation, - sender_chain: postcard::to_allocvec(&chain) - .map_err(|_| StoreError::Serialization)?, - sent_since_rotation: 0, - pending, - }, - rng, - )?; - } - for message in &snapshot.group_messages { - self.put_group_message(message, rng)?; - } - for authority in &snapshot.group_authorities { - self.put_group_authority(authority, rng)?; - } - for record in &snapshot.local_metadata { - if matches!(record, LocalMetadataRecord::Draft(_)) { - return Err(StoreError::Serialization); - } - self.put_local_metadata(record, rng)?; - } - for message in &snapshot.note_messages { - self.put_note_message(message, rng)?; - } - for record in &snapshot.ephemeral_tombstones { - if record.state == EphemeralState::Active || !record.transfer_ids.is_empty() { - return Err(StoreError::Serialization); - } - self.put_ephemeral_record(record, rng)?; - } - for event in &snapshot.sync_events { - self.put_device_sync_event(event, rng)?; - } - Ok(()) - } - /// Atomically replace the complete sealed local linked-device state. pub fn put_device_state( &self, @@ -568,6 +529,49 @@ impl Store { Ok(Some(state)) } + /// Load one committed link-package recovery handle. + pub fn get_device_link_recovery( + &self, + target_device: &[u8; 32], + ) -> Result> { + let Some(row) = self.get_equality::( + &store_v2::AccountKey::new(*target_device), + )? + else { + return Ok(None); + }; + row.verify_key(&store_v2::AccountKey::new(*target_device))?; + row.verify_indexes(&store_v2::IndexKeys::none())?; + let recovery: DeviceLinkRecoveryRecord = decode_exact(&row.payload)?; + validate_device_link_recovery(&recovery)?; + if recovery.target_device != *target_device { + return Err(StoreError::LogicalKeyMismatch); + } + Ok(Some(recovery)) + } + + pub(crate) fn put_device_link_recovery( + &self, + recovery: &DeviceLinkRecoveryRecord, + rng: &mut impl CryptoRngCore, + ) -> Result<()> { + validate_device_link_recovery(recovery)?; + if self + .get_device_link_recovery(&recovery.target_device)? + .is_none() + && self.count_rows::()? >= MAX_LINKED_DEVICES as u64 + { + return Err(StoreError::RecordBounds); + } + let encoded = postcard::to_allocvec(recovery).map_err(|_| StoreError::Serialization)?; + self.put_equality::( + &store_v2::AccountKey::new(recovery.target_device), + &encoded, + store_v2::IndexKeys::none(), + rng, + ) + } + /// Insert one opaque authenticated sync event if its exact bytes are new. /// Returns `true` only for a new durable row. pub fn put_device_sync_event( @@ -610,26 +614,19 @@ impl Store { Ok(events) } - /// Delete every event except the exact supplied set after a verified - /// compaction snapshot commits. Event bytes remain sealed lookup keys. - pub fn retain_device_sync_events(&self, retain: &[Vec]) -> Result<()> { - let wanted: HashSet<&[u8]> = retain.iter().map(Vec::as_slice).collect(); - let mut remove = Vec::new(); - for row in self.rows::()? { - row.verify_key(&self.device_sync_digest(&row.payload))?; - if !wanted.contains(row.payload.as_slice()) { - remove.push(row.rowid); - } - } - let tx = self.conn.unchecked_transaction()?; - for rowid in remove { - self.delete_rowid_on::(&tx, rowid)?; + pub(crate) fn delete_device_sync_event(&self, event: &[u8]) -> Result { + let digest = self.device_sync_digest(event); + let Some(row) = self.row_by_unique::(&digest)? else { + return Ok(false); + }; + row.verify_key(&digest)?; + if row.payload.as_slice() != event { + return Err(StoreError::LogicalKeyMismatch); } - tx.commit()?; - Ok(()) + self.delete_row::(&row.locator) } - fn device_sync_digest(&self, event: &[u8]) -> store_v2::DigestKey { + pub(crate) fn device_sync_digest(&self, event: &[u8]) -> store_v2::DigestKey { let mut input = Vec::with_capacity(DEVICE_SYNC_DIGEST_DOMAIN.len() + event.len()); input.extend_from_slice(DEVICE_SYNC_DIGEST_DOMAIN); input.extend_from_slice(event); @@ -644,6 +641,7 @@ impl Store { let state: DeviceStateRecord = decode_exact(&row.payload)?; state.validate(account.as_ref().ok_or(StoreError::NotAStore)?) })?; + let device_state = self.get_device_state()?; self.validate_rows::(|row| { if row.payload.is_empty() || row.payload.len() > MAX_DEVICE_SYNC_EVENT_BYTES { return Err(StoreError::Serialization); @@ -680,11 +678,43 @@ impl Store { &store_v2::ContentKey::new(delivery.message), &store_v2::AccountKey::new(delivery.account), )) - }) + })?; + self.validate_rows::(|row| { + let recovery: DeviceLinkRecoveryRecord = decode_exact(&row.payload)?; + validate_device_link_recovery(&recovery)?; + row.verify_key(&store_v2::AccountKey::new(recovery.target_device))?; + row.verify_indexes(&store_v2::IndexKeys::none())?; + let state = device_state.as_ref().ok_or(StoreError::NotAStore)?; + if !state.manifest.devices.iter().any(|entry| { + entry.certificate.device_id() == recovery.target_device + && entry.revoked_at.is_none() + }) || !state + .channels + .iter() + .any(|channel| channel.peer_device == recovery.target_device) + { + return Err(StoreError::LogicalKeyMismatch); + } + Ok(()) + })?; + if self.count_rows::()? > MAX_LINKED_DEVICES as u64 { + return Err(StoreError::RecordBounds); + } + Ok(()) + } +} + +pub(crate) fn validate_device_link_recovery(recovery: &DeviceLinkRecoveryRecord) -> Result<()> { + if recovery.target_device == [0u8; 32] + || recovery.response_hash == [0u8; 32] + || recovery.link_key == [0u8; 32] + { + return Err(StoreError::Serialization); } + Ok(()) } -fn validate_contact_device(endpoint: &ContactDeviceRecord) -> Result<()> { +pub(crate) fn validate_contact_device(endpoint: &ContactDeviceRecord) -> Result<()> { if endpoint.account == [0u8; 32] || endpoint.device == [0u8; 32] || (endpoint.certificate.is_empty() && endpoint.account != endpoint.device) diff --git a/crates/kult-store/src/lib.rs b/crates/kult-store/src/lib.rs index 35ebfe6..6f1849e 100644 --- a/crates/kult-store/src/lib.rs +++ b/crates/kult-store/src/lib.rs @@ -45,25 +45,29 @@ mod store_v2; pub use backup::BACKUP_MAGIC; pub use commit::{ - AttachmentStagePlan, AttachmentStatePlan, CommitPlan, CommitReceipt, CommittedRecordIds, - ContactDeviceDelete, ContactTransition, DeferredControlKind, DeferredControlRecord, - DeliveryTransition, EphemeralTransition, GroupAuthorityStateTransition, - GroupAuthorityTransition, GroupChainStateTransition, GroupChainTransition, GroupMessageDelete, - GroupMessageTransition, GroupReceivePlan, GroupSendPlan, GroupStatePlan, GroupStateTransition, - GroupTransition, HandshakeReceivePlan, MaintenancePlan, MediaDelete, MediaObjectTransition, + AttachmentStagePlan, AttachmentStatePlan, CapabilityDelete, CommitPlan, CommitReceipt, + CommittedRecordIds, ContactDeviceDelete, ContactTransition, DeferredControlKind, + DeferredControlRecord, DeliveryTransition, DeviceControlPlan, DeviceLinkPlan, + DeviceLinkRecoveryTransition, DeviceProjection, DeviceProjectionPlan, DeviceStateTransition, + EphemeralTransition, GroupAuthorityStateTransition, GroupAuthorityTransition, + GroupChainStateTransition, GroupChainTransition, GroupMessageDelete, GroupMessageTransition, + GroupReceivePlan, GroupSendPlan, GroupStatePlan, GroupStateTransition, GroupTransition, + HandshakeReceivePlan, IdentityTransition, MaintenancePlan, MediaDelete, MediaObjectTransition, MediaTransferTransition, MessageDelete, MessageTransition, PairwiseReceivePlan, - PairwiseSendPlan, PendingDelete, PrekeyPublishPlan, PrekeyTransition, QueueDelete, - QueueTransition, ReceiptReceivePlan, SessionTransition, MAX_ATTACHMENT_STAGE_MUTATIONS, - MAX_COMMIT_MUTATIONS, MAX_COMMIT_QUEUE_ROWS, MAX_DEFERRED_CONTROLS, MAX_GROUP_COMMIT_MUTATIONS, - MAX_GROUP_COMMIT_QUEUE_ROWS, MAX_GROUP_STATE_MUTATIONS, MAX_MAINTENANCE_TRANSITIONS, - MAX_PAIRWISE_COMMIT_DEVICES, + PairwiseSendPlan, PendingDelete, PrekeyPublishPlan, PrekeyTransition, ProfileBootstrapPlan, + QueueDelete, QueueTransition, ReceiptReceivePlan, SessionDelete, SessionTransition, + MAX_ATTACHMENT_STAGE_MUTATIONS, MAX_COMMIT_MUTATIONS, MAX_COMMIT_QUEUE_ROWS, + MAX_DEFERRED_CONTROLS, MAX_DEVICE_CONTROL_MUTATIONS, MAX_DEVICE_LINK_MUTATIONS, + MAX_DEVICE_PROJECTION_MUTATIONS, MAX_GROUP_COMMIT_MUTATIONS, MAX_GROUP_COMMIT_QUEUE_ROWS, + MAX_GROUP_STATE_MUTATIONS, MAX_MAINTENANCE_TRANSITIONS, MAX_PAIRWISE_COMMIT_DEVICES, + MAX_PROFILE_GROUPS, }; #[cfg(feature = "test-failpoints")] pub use commit::{CommitFailpoint, CommitFailure}; pub use devices::{ - ContactDeviceRecord, DeviceChannelRecord, DeviceStateRecord, DeviceTransferGroup, - DeviceTransferSelection, DeviceTransferSnapshot, MessageDeviceDeliveryRecord, - MAX_DEVICE_SYNC_EVENTS, MAX_DEVICE_SYNC_EVENT_BYTES, + ContactDeviceRecord, DeviceChannelRecord, DeviceLinkRecoveryRecord, DeviceStateRecord, + DeviceTransferGroup, DeviceTransferSelection, DeviceTransferSnapshot, + MessageDeviceDeliveryRecord, MAX_DEVICE_SYNC_EVENTS, MAX_DEVICE_SYNC_EVENT_BYTES, }; pub use ephemeral::{EphemeralConversation, EphemeralMode, EphemeralRecord, EphemeralState}; pub use local_metadata::{ @@ -117,6 +121,8 @@ pub enum StoreError { /// A typed protocol transition did not match the durable source state it /// named and was rolled back without changing the store. InvalidTransition, + /// The durable profile group limit is exhausted. + GroupLimit, /// The bounded deferred-inbox item or sealed-byte quota is exhausted. PendingQuota, /// Configured or protocol-hard media quota would be exceeded. @@ -218,6 +224,7 @@ impl std::fmt::Display for StoreError { Self::Io(e) => write!(f, "store filesystem error: {e}"), Self::AlreadyOpen => f.write_str("store is already open by another process"), Self::InvalidTransition => f.write_str("invalid durable protocol transition"), + Self::GroupLimit => f.write_str("profile group limit exhausted"), Self::PendingQuota => f.write_str("deferred inbox quota exhausted"), Self::MediaQuota => f.write_str("media quota exceeded"), Self::LowStorage => f.write_str("insufficient reserved filesystem space"), @@ -807,6 +814,7 @@ impl Store { let store = Self::open_v2_with_parts(path, passphrase, conn, database_lock, lock, false)?; migration::cleanup_completed_replacement(path)?; backup::cleanup_completed_restore(path)?; + backup::cleanup_completed_initialization(path)?; Ok(store) } @@ -965,6 +973,9 @@ impl Store { } row.verify_indexes(&store_v2::IndexKeys::none()) })?; + if self.count_rows::()? > MAX_PROFILE_GROUPS as u64 { + return Err(StoreError::GroupLimit); + } self.validate_rows::(|row| { let record: GroupAuthorityRecord = decode_exact(&row.payload)?; row.verify_key(&store_v2::GroupKey::new(record.group))?; @@ -1546,14 +1557,15 @@ impl Store { /// Insert or replace a group (sealed). pub fn put_group(&self, rec: &GroupRecord, rng: &mut impl CryptoRngCore) -> Result<()> { + let key = store_v2::GroupKey::new(rec.id); + if self.get_equality::(&key)?.is_none() + && self.count_rows::()? >= MAX_PROFILE_GROUPS as u64 + { + return Err(StoreError::GroupLimit); + } let plain = Zeroizing::new(postcard::to_allocvec(rec).map_err(|_| StoreError::Serialization)?); - self.put_equality::( - &store_v2::GroupKey::new(rec.id), - &plain, - store_v2::IndexKeys::none(), - rng, - )?; + self.put_equality::(&key, &plain, store_v2::IndexKeys::none(), rng)?; Ok(()) } diff --git a/crates/kult-store/src/local_metadata.rs b/crates/kult-store/src/local_metadata.rs index 195265e..0ea8f5a 100644 --- a/crates/kult-store/src/local_metadata.rs +++ b/crates/kult-store/src/local_metadata.rs @@ -463,7 +463,7 @@ impl LocalMetadataRecord { } } - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { let string_ok = |value: &str| !value.is_empty() && value.len() <= MAX_LOCAL_METADATA_STRING_BYTES; let valid = match self { diff --git a/crates/kult-store/src/note.rs b/crates/kult-store/src/note.rs index 77fd3aa..02cdb87 100644 --- a/crates/kult-store/src/note.rs +++ b/crates/kult-store/src/note.rs @@ -29,7 +29,7 @@ pub struct NoteMessageRecord { } impl NoteMessageRecord { - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { if self.body.is_empty() || self.body.len() > MAX_NOTE_TEXT_BYTES { Err(StoreError::NoteBounds) } else { diff --git a/crates/kult-store/src/store_v2.rs b/crates/kult-store/src/store_v2.rs index 9f588b1..21b897d 100644 --- a/crates/kult-store/src/store_v2.rs +++ b/crates/kult-store/src/store_v2.rs @@ -580,6 +580,7 @@ table!(ContactDeviceRows, 24, Equality, AccountDeviceKey); table!(MessageDeviceDeliveryRows, 25, Equality, MessageDeviceKey); table!(PresentationMarkerRows, 26, Equality, SingletonKey); table!(DeferredControlRows, 27, Equality, ContentKey); +table!(DeviceLinkRecoveryRows, 28, Equality, AccountKey); pub(crate) struct MigrationCheckpointRows; impl TableSpec for MigrationCheckpointRows { @@ -1873,7 +1874,7 @@ fn validate_index_shape(domain: u8, indexes: &IndexKeys) -> Result<()> { DeviceSyncRows::DOMAIN => [true, false, false, false, false], ContactDeviceRows::DOMAIN => [false, true, false, false, false], MessageDeviceDeliveryRows::DOMAIN => [false, true, true, false, false], - 1..=27 => [false; 5], + 1..=28 => [false; 5], MigrationCheckpointRows::DOMAIN => [false; 5], _ => return Err(StoreError::SchemaMismatch), }; @@ -1911,6 +1912,7 @@ fn table_locator_kind(domain: u8) -> Result { | MessageDeviceDeliveryRows::DOMAIN | PresentationMarkerRows::DOMAIN | DeferredControlRows::DOMAIN + | DeviceLinkRecoveryRows::DOMAIN | MigrationCheckpointRows::DOMAIN => Ok(LocatorKind::Equality), MessageRows::DOMAIN | QueueRows::DOMAIN @@ -1929,9 +1931,11 @@ fn validate_key_for_domain(domain: u8, key: &[u8]) -> Result<()> { | DeviceStateRows::DOMAIN | PresentationMarkerRows::DOMAIN | MigrationCheckpointRows::DOMAIN => SingletonKey::validate_encoded(key), - SessionRows::DOMAIN | CapabilityRows::DOMAIN | ContactRows::DOMAIN | ResetRows::DOMAIN => { - AccountKey::validate_encoded(key) - } + SessionRows::DOMAIN + | CapabilityRows::DOMAIN + | ContactRows::DOMAIN + | ResetRows::DOMAIN + | DeviceLinkRecoveryRows::DOMAIN => AccountKey::validate_encoded(key), MessageRows::DOMAIN => MessageKey::validate_encoded(key), QueueRows::DOMAIN | PendingRows::DOMAIN => OpaqueRowKey::validate_encoded(key), SeenRows::DOMAIN diff --git a/docs/29-stabilization-program.md b/docs/29-stabilization-program.md index de9db34..1d12510 100644 --- a/docs/29-stabilization-program.md +++ b/docs/29-stabilization-program.md @@ -234,7 +234,7 @@ This table prevents a prior concern from disappearing into roadmap prose. | Video, large groups, new carriers, federation, and governance expansion could distract from everyday reliability | P0-03, P2 | | Direct transport currently acknowledges volatile RAM before bounded durable admission can accept or refuse an unknown token | P0-05 | | Stable identity-derived DHT locators and public route hints permit polling and network-location correlation | P0-04, P0-05, P0-06 | -| Typed atomic plans and restart injection now cover pairwise, group, attachment, scheduled activation and bounded maintenance paths; linked-device authority/sync, first-contact admission, initial profile creation and leased relay custody remain open in the recorded inventory | P0-03, P0-06 | +| Typed atomic plans and restart injection now cover profile bootstrap, pairwise, group, attachment, scheduled activation, bounded maintenance and the current linked-device transition implementation; the authority design, first-contact admission/legacy bridge, leased relay custody and independent/power-loss evidence remain open in the recorded inventory | P0-03, P0-06 | | Mailbox collection deletes relay custody before the endpoint durably stages and acknowledges a leased page | P0-04, P0-08 | | Unix store writer exclusion now combines a no-follow sidecar with a database-inode lock; equivalent alias resistance and hostile-filesystem qualification remain open on other supported platforms | P0-06, P0-09 | | The Unix RPC sidecar is no-follow and owner-only, but portable stale-socket replacement still requires a daemon-owned parent directory to exclude hostile rename/unlink races | P0-07, P0-09 | diff --git a/docs/31-release-evidence-ledger.md b/docs/31-release-evidence-ledger.md index f47b2d4..1ec7ca7 100644 --- a/docs/31-release-evidence-ledger.md +++ b/docs/31-release-evidence-ledger.md @@ -4,8 +4,9 @@ **Release scope:** stable-v1 candidate -**Current evidence baseline:** [`main@63ef901`](https://github.com/AndriGitDev/Komms/tree/63ef901e800a5b379a7088202523437fc8f1a7d1); -the atomic pairwise change was merged by PR #81 +**Current evidence baseline:** [`main@5a0addc`](https://github.com/AndriGitDev/Komms/tree/5a0addcff54a07d412f54187d691bf489bd08b56); +the stable protocol-transition expansion was merged by +[PR #82](https://github.com/AndriGitDev/Komms/pull/82) **Baseline automated run:** [`a02b064`, CI run 197](https://github.com/AndriGitDev/Komms/actions/runs/30199264838); this successful PR-head tree is the tree merged by PR #77 @@ -36,7 +37,7 @@ accepts it and the assignment is recorded in |---|---|---|---|---|---| | **P0-01 Honest claims and evidence ledger** | Andri (FND; interim SEC/PROD) | Designed: vocabulary, frozen claim register, and this ledger exist; the repository documentation check passed in release-control CI. | **Open.** External website and repository-description corrections remain; no stable release evidence bundle exists. | [Stabilization vocabulary](29-stabilization-program.md#2-evidence-vocabulary); [stable-v1 profile](30-stable-v1-product-profile.md); [CI run 199](https://github.com/AndriGitDev/Komms/actions/runs/30202463092); this ledger; [public-copy follow-up](#4-public-copy-audit) | 2026-08-09 | | **P0-02 Name-risk assessment and recorded decision** | Andri (FND and project risk owner); qualified trademark counsel: **Unassigned** | Designed: dated founder decision, observed overlap, migration cost, cadence, and escalation triggers recorded. | **Open.** This is not legal clearance. No qualified similarity/class/jurisdiction opinion or trademark/asset policy exists. | [Name-risk decision](32-name-risk-decision.md); [brand system](28-brand-system.md) | 2026-10-26, or trigger event | -| **P0-03 Stabilized core product profile** | Andri (FND; interim PROD/SEC) | Designed: product boundary, bounds, supported-system rule, services, and exclusions frozen. Typed pairwise, group, attachment, scheduled-activation and bounded-maintenance plans have deterministic crash/restart tests retained by CI run 214. | **Open.** ADR-0026 and ADR-0028 through ADR-0032 remain proposed. Linked-device authority/sync, first-contact admission, initial profile creation and leased relay custody remain outside the complete plan matrix; field qualification and independent review have not closed those gaps. | [Stable-v1 profile](30-stable-v1-product-profile.md); [atomic inventory](34-atomic-transition-inventory.md); [ADR-0028](adr/0028-atomic-protocol-commits.md); [commit plans](../crates/kult-store/src/commit.rs); [crash matrix](../crates/kult-node/src/atomic_tests.rs); [branch revision](https://github.com/AndriGitDev/Komms/commit/f17a60636a074b889105d7e123caf0fa475bebfc); [CI run 214](https://github.com/AndriGitDev/Komms/actions/runs/30281155361); [P0 ADR index](adr/README.md) | 2026-08-09 | +| **P0-03 Stabilized core product profile** | Andri (FND; interim PROD/SEC) | Designed: product boundary, bounds, supported-system rule, services, and exclusions frozen. Fifteen typed plans now cover profile bootstrap, pairwise, group, attachment, scheduled activation, bounded maintenance, current linked-device control/link/sync transitions and restart presentation. The device-control bound reserves one transaction for a full sync bundle plus every permitted group rotation. Retained CI run 217 covers the expanded matrix, cross-shell core and application builds, desktop, Windows storage, MSRV, `no_std`, dependency policy and all 22 full-budget fuzz targets. | **Open.** ADR-0026 and ADR-0028 through ADR-0032 remain proposed. The current linked-device authority design, first-contact admission/legacy alias bridge and leased relay custody remain outside stable-v1 acceptance; field qualification, sudden-power-loss evidence and independent review are also open. | [Stable-v1 profile](30-stable-v1-product-profile.md); [atomic inventory](34-atomic-transition-inventory.md); [ADR-0028](adr/0028-atomic-protocol-commits.md); [commit plans](../crates/kult-store/src/commit.rs); [crash matrix](../crates/kult-node/src/atomic_tests.rs); [baseline revision](https://github.com/AndriGitDev/Komms/commit/f17a60636a074b889105d7e123caf0fa475bebfc); [expanded revision](https://github.com/AndriGitDev/Komms/commit/6c73f71b18f120e5a3072fe77dfcd122cbf287dd); [CI run 217](https://github.com/AndriGitDev/Komms/actions/runs/30303253034); [P0 ADR index](adr/README.md) | 2026-08-09 | | **P0-04 Clean-install and real-network golden path** | Andri (interim NET/PROD); independent field evaluator: **Unassigned** | Implemented with local/CI evidence for internet components and shells. | **Open.** No qualified default bootstrap/mailbox, clean-device distinct-NAT matrix, default-blackhole journey, replacement operator, or pure-core journey. | [Internet tests](../crates/kult-node/tests/internet_e2e.rs); [Alpha guide](27-alpha-testing.md); [ADR-0034](adr/0034-operator-minimized-reference-discovery.md) | 2026-08-09 | | **P0-05 Unsolicited-contact abuse admission** | Andri (interim SEC/NET/PROD); independent adversarial/usability evaluator: **Unassigned** | Designed, with automated evidence for interim byte/count bounds. | **Open.** No pre-KEM admission, durable request inbox, consent promotion, identity block implementation, or adversarial/field evidence. Current first contact can create normal contact state. | [ADR-0030](adr/0030-first-contact-admission.md); [transport bounds](05-transports.md); [protocol tests](../crates/kult-protocol/tests/protocol.rs) | 2026-08-09 | | **P0-06 Independent crypto and protocol assurance** | Andri (interim SEC); independent cryptography reviewer: **Unassigned**; independent interoperability implementer: **Unassigned** | Automated evidence for local KATs, properties, sessions, and protocol decoding. | **Open.** No external vectors, separate implementation, review scope, findings report, disposition, or residual-risk statement. P0 protocol/security ADRs remain proposed. | [Baseline CI](https://github.com/AndriGitDev/Komms/actions/runs/30199264838); [Crypto KATs](../crates/kult-crypto/tests/kat.rs); [properties](../crates/kult-crypto/tests/properties.rs); [session tests](../crates/kult-crypto/tests/session.rs); [cryptography spec](04-cryptography.md) | 2026-08-09 | diff --git a/docs/34-atomic-transition-inventory.md b/docs/34-atomic-transition-inventory.md index ac73d55..bdf716b 100644 --- a/docs/34-atomic-transition-inventory.md +++ b/docs/34-atomic-transition-inventory.md @@ -17,10 +17,11 @@ atomicity. ## 1. Transaction contract -The store exposes eleven bounded protocol plan kinds: +The store exposes fifteen bounded protocol plan kinds: | Plan | One logical transition | Principal bound | |---|---|---| +| `ProfileBootstrap` | Publish one fresh account identity, physical-device state and prekey vault inside an unpublished sibling store | Three exact singleton rows | | `PrekeyPublish` | Issue one fresh out-of-band one-time-prekey bundle and replace the exact vault that owns it | One exact vault replacement | | `PairwiseSend` | Advance up to eight device sessions and retain every resulting ciphertext with its history, delivery, schedule, attachment, or control consequence | 8 sessions, 128 queue rows, 512 mutations | | `PairwiseReceive` | Accept one pairwise plaintext/control and advance its receiving and optional receipt-sending state | 128 queue rows, 512 mutations | @@ -29,6 +30,9 @@ The store exposes eleven bounded protocol plan kinds: | `GroupSend` | Advance one sender chain or perform one late fan-out and retain all recipient-scoped copies and delivery rows | 64 accounts, 8 devices per account, 512 queue rows, 2,048 mutations | | `GroupReceive` | Advance one receiver chain and retain the accepted plaintext consequence plus its encrypted receipt | One group chain and one receipt session | | `GroupState` | Apply one roster, authority, announcement, receiver-chain, removal, or deferred group-control transition | 256 exact mutations | +| `DeviceControl` | Replace current device authority/counters, rotate affected group senders, append/compact convergence events, or transition one link-package recovery handle | 8,192 exact mutations; 4,094 groups per profile | +| `DeviceLink` | Atomically switch one confirmed pristine target to the linked account and import its selected snapshot | 8,192 imported records; 4,094 groups | +| `DeviceProjection` | Apply one already-durable convergence winner and retire any exact session/capability/queue consequences | 512 exact mutations | | `AttachmentStage` | Create the bounded metadata graph for one outbound attachment manifest | 256 mutations | | `AttachmentState` | Apply one bounded transfer/object/missing-range/deferred-control transition | 256 mutations | | `Maintenance` | Apply one bounded retry, expiry, tombstone, terminal-input, repair, queue, or presentation acknowledgement transition | 256 exact mutations | @@ -36,9 +40,12 @@ The store exposes eleven bounded protocol plan kinds: Every plan validates its complete before-state before `BEGIN IMMEDIATE`. `CommitPlan` is the only protocol-state write surface used by stable-profile node modules. The source guard in `atomic_tests.rs` rejects direct session, -group, history, delivery, queue, replay, ephemeral, and media setters in those -modules. The one quarantined exception is the current linked-device Alpha -module described in section 4. +group, history, delivery, queue, replay, ephemeral, media and device-state +setters in those modules. It audits `devices.rs` rather than excluding the +file; only the explicitly delimited pre-C2 contact-manifest bridge described in +section 4 is removed from that check. The former multi-autocommit link-snapshot +import and convergence-log retention entry points have been removed; their only +production replacements are `DeviceLink` and `DeviceControl`. The ownership rules are structural: @@ -53,6 +60,9 @@ The ownership rules are structural: - a consumed one-time prekey owns the newly established exact-device session; issued one-time prekeys become visible to the caller only after the replacement vault commits; +- a confirmed link secret remains live until the new channel and manifest + commit; a sealed recovery handle owns a package return value lost after + commit and is cleared only by authenticated target activity; - detached candidates replace live memory only after the commit receipt; - a presentation marker commits with every visible change, so a restart after commit but before event delivery requires a complete snapshot resync. @@ -89,6 +99,11 @@ that transition commits and restart reconciliation removes abandoned files. | Retry, expiry, terminal rejection and stale-session reset | Queue schedule/removal, delivery state, replay, session/capability reset | `Maintenance` | Work is paged at 256 mutations; retryable input remains durable | | Event-delivery recovery | Sealed presentation marker | Visible plan plus `Maintenance` acknowledgement | Reopen emits `StateResyncRequired`; acknowledgement follows delivery | | Media restart reconciliation | Missing-file object state and abandoned filesystem rows | Paged `AttachmentState` | Metadata repair commits before orphan cleanup; each page is bounded | +| Fresh profile creation | Account identity, physical-device authority and fresh prekey vault | `ProfileBootstrap` inside sibling publication | Destination is absent or a complete openable profile; no partial identity path is published | +| Device rename, approval, revocation and channel counters | Signed manifest, exact channel state, affected group sender chains and convergence events | `DeviceControl` | Detached memory follows commit; the 4,094-group profile ceiling leaves room for a full 4,096-event bundle, authority and recovery retirement while revocation rotates every group chain in the same transaction | +| Confirmed device-link completion | Account identity, target device/channel state, regenerated local group senders and selected records | `DeviceLink` | The source first seeds convergence winners for the snapshot, then exports only the selected namespaces; one bounded pristine-target transaction consumes the target ceremony secret only after success | +| Link-package return recovery | Source manifest/channel and transcript-derived recovery key | `DeviceControl` | Approval commits a small sealed recovery handle; retry after restart reseals from committed state, and authenticated target sync deletes it | +| Device-sync import and duplicate import | Manifest/counter, convergence events, revocation rotations and exact winner projections | `DeviceControl`, then idempotent `DeviceProjection` / `GroupState` | Accepted control state commits before projections; restart reapplies winners, exact opaque event rows are retired by their resolved locator, group/authority tombstones remove their complete state, and sequence replay is rejected without writes | | Backup export | A read-only encrypted snapshot with fresh mnemonic | No live-state transition | Export excludes ratchets, prekeys, queues, live ephemeral plaintext/media and call state | | Backup restore | New sibling database, reset markers, fresh device state and fresh prekeys | Sibling-store initialization plus atomic filesystem replacement | The destination is absent or a complete openable store; a visible restored identity never lacks fresh non-portable secrets | @@ -110,40 +125,40 @@ write. The remaining direct calls fall into these categories: | Complete-envelope `pending_push` | Bounded idempotent ingress staging before parsing or cryptographic work | | Media garbage collection after semantic commit | Physical cleanup after the durable tombstone/progress transition | | Contact import, hint/verification changes | Current automatic-contact Alpha flow; outside stable-v1 pending ADR-0030 | -| `devices.rs` authority, linking and sync setters | Current ADR-0024 Alpha authority; outside stable-v1 pending ADR-0026 | +| Pre-C2 contact-device alias/manifest migration | Explicitly delimited ADR-0030 compatibility quarantine; its route/session retarget sequence is not stable-v1 evidence | +| Restore population writes inside an unpublished sibling | Bounded reconstruction work is never visible at the destination; fresh device/prekey initialization is typed before atomic publication | -No stable-profile sender/receiver chain, one-time prekey consumption, group -state, protocol history, delivery, outbound queue, replay/seen, attachment -state, or ephemeral tombstone is written through those local-state calls. -Adding such a call to a stable protocol module fails the source guard. +No stable-profile sender/receiver chain, one-time prekey consumption, current +device authority/counter, link import, convergence winner, group state, +protocol history, delivery, outbound queue, replay/seen, attachment state, or +ephemeral tombstone is written through those local-state calls. Adding such a +call outside the named compatibility bridge fails the source guard. ## 4. Open and excluded paths These boundaries keep ADR-0028 Proposed: -1. **Linked-device authority and sync are an open P0 path, not a P2 - exception.** The current ADR-0024 implementation copies the account root and - applies linking, manifest, counter, convergence-log and projection writes - through the quarantined `devices.rs` path. The stable-v1 authority required - by ADR-0026 does not exist yet, so there is no honest stable transition to - freeze. ADR-0026 must define its typed manifest, root-recovery, link-package - and sync-import plans and add them to this matrix. +1. **Linked-device authority design remains an open P0 path, not a P2 + exception.** The current ADR-0024 implementation copies the account root, + so a revoked device can still mint replacement credentials. Its implemented + link, manifest, channel, sync-log and projection writes are now typed and + crash-safe, but that transaction evidence does not satisfy ADR-0026's + offline-root and majority-authority design. 2. **First-contact admission is an open P0 path.** Current explicit contact import can update the contact and endpoint rows before the future ADR-0030 consent/quarantine transition exists. The current inbound cryptographic handshake itself is atomic, but the automatic-contact Alpha policy is - outside stable-v1. -3. **Initial profile creation is a bootstrap boundary.** It creates no prior - live state and returns no usable node until identity, device state and - prekeys all exist, but an interrupted initial create can leave an unusable - partial profile path. Crash-safe sibling creation remains required before - ADR-0028 acceptance. -4. **Live call state is process-local by design.** Ratchet-protected signalling + outside stable-v1. The pre-C2 alias migration is the only raw setter sequence + excluded from the node source guard. +3. **Live call state is process-local by design.** Ratchet-protected signalling is covered; ringing, active-call and media state are not restored after a process stop and are not stable persisted state. -5. **Mailbox-v1 relay custody remains outside this node/store transaction.** +4. **Mailbox-v1 relay custody remains outside this node/store transaction.** Endpoint inbox acceptance is covered, but leased relay deletion only after endpoint acknowledgement remains proposed in ADR-0032. +5. **Independent and physical evidence remains open.** The deterministic + matrix is not independent protocol review or supported-platform sudden + power-loss qualification. The intentionally excluded P2 paths are live video, groups above 64 accounts, advanced moderation, high-bandwidth media, Freenet-style or other additional @@ -154,7 +169,8 @@ or against stable-v1 atomicity. ## 5. Failure and restart matrix `crates/kult-node/src/atomic_tests.rs` applies every transaction failpoint to -all eleven plan kinds: +all fifteen plan kinds, using seventeen fixtures where maintenance has separate +terminal-input, session-reset and expiry cases: - before and after `BEGIN IMMEDIATE`; - before and after every numbered logical statement; @@ -163,13 +179,18 @@ all eleven plan kinds: - before and after event delivery; and - disk-full, constraint and duplicate-index failure classes. -The same suite covers duplicate and reordered deferred input, retry after -restart, presentation-outbox recovery, scheduled activation, and a maximum -stable-v1 group fan-out of 504 physical deliveries. The group end-to-end suite -covers partial carrier handoff followed by sender restart. Media tests cover -duplicate chunks, interrupted temporary files, exact missing ranges and full -quotas. Backup tests inject every atomic-replacement phase and initializer -failure. +The same suite covers duplicate and reordered deferred input, duplicate device +sync import, retry after restart, presentation-outbox recovery, link-package +return recovery, scheduled activation, a maximum stable-v1 group fan-out of +504 physical deliveries, and rejection/restart at the profile group ceiling. +The linked-device suite also proves selective initial transfer, exact +convergence-event compaction, group deletion/authority tombstones and restart +replay. The group end-to-end suite covers partial carrier handoff followed by +sender restart. Pending-inbox, media and custom-icon tests fill their item/byte +quotas; media tests also cover duplicate chunks, interrupted temporary files +and exact missing ranges. Profile and backup tests inject every +atomic-replacement phase and initializer failure. Disk-full, constraint and +duplicate-index classes run against every plan fixture. For each injected point, reopen observes either the complete transition or its complete absence. The input remains retryable in the absent case; the durable diff --git a/docs/adr/0028-atomic-protocol-commits.md b/docs/adr/0028-atomic-protocol-commits.md index e08878d..46aff66 100644 --- a/docs/adr/0028-atomic-protocol-commits.md +++ b/docs/adr/0028-atomic-protocol-commits.md @@ -30,11 +30,15 @@ work only on those candidate values and creates one bounded typed commit plan: - `PairwiseSend`; - `PairwiseReceive`; +- `ProfileBootstrap`; - `PrekeyPublish`; - `HandshakeReceive`; - `GroupSend`; - `GroupReceive`; - `GroupState`; +- `DeviceControl`; +- `DeviceLink`; +- `DeviceProjection`; - `AttachmentStage`; - `AttachmentState`; - `ReceiptReceive`; or @@ -145,14 +149,36 @@ or plaintext consequence. ### 7. Implementation and evidence status -The implementation now provides all eleven plan kinds above. They cover +The implementation now provides all fifteen plan kinds above. They cover pairwise and group text, edits, polls, roles, authority changes, group announcements and bounded fan-out, pairwise and group attachments, missing ranges, ephemeral/view-once state, scheduled activation, call signalling, late-device delivery, exact deferred-control acknowledgement, retry/expiry, -session repair, media reconciliation, and presentation recovery. Fresh -out-of-band one-time-prekey issuance uses `PrekeyPublish`; inbound consumption -uses `HandshakeReceive` and cannot commit without the established session. +session repair, current linked-device authority/counter changes, confirmed link +imports, convergence projection, media reconciliation, profile bootstrap, and +presentation recovery. Fresh out-of-band one-time-prekey issuance uses +`PrekeyPublish`; inbound consumption uses `HandshakeReceive` and cannot commit +without the established session. + +`ProfileBootstrap` commits a fresh account identity, physical-device authority +state, and prekey vault inside an unpublished sibling database. The sibling is +file- and directory-synchronized before one atomic replacement publishes the +profile, so interruption leaves either no destination or one complete openable +profile. Recovery initializes fresh device state and fresh prekeys before the +same sibling-publication boundary. + +`DeviceControl`, `DeviceLink`, and `DeviceProjection` cover the current +ADR-0024 implementation without endorsing its authority design. Manifest +rename/revocation, channel counters, convergence events and group rotations +commit together; a confirmed pristine target switches identity, authority and +its bounded selected snapshot in one transaction; accepted event winners are +projected through exact idempotent before/after plans. A link ceremony secret +is retained until its channel commits. The source also commits a small sealed +recovery handle with link approval, allowing a package return value lost after +commit to be resealed after restart. Authenticated target sync removes that +handle. Profiles admit at most 4,094 groups, leaving one `DeviceControl` +transaction enough space for every group-chain rotation, a maximum +4,096-event sync bundle, device authority and recovery retirement. Each plan validates its bounded before/after relationships before `BEGIN IMMEDIATE`. The transaction writes the detached candidate and every @@ -174,21 +200,24 @@ exercises before/after cryptographic steps, every logical transaction statement, commit, memory replacement, event delivery, disk-full, constraint, duplicate, reorder, deferred-input, expiry, session-repair, scheduled activation, maximum stable-v1 group fan-out, and restart cases for every plan -kind. Group end-to-end evidence covers partial carrier handoff and restart; -media evidence covers duplicate chunks, interrupted files and full quotas; -backup evidence covers each replacement phase and fresh-secret initialization. +kind. Linked-device evidence additionally covers retained ceremony secrets, +lost-return recovery, duplicate import, profile group-limit rejection and +event-outbox recovery. Group end-to-end evidence covers partial carrier handoff +and restart; pending-inbox, media and custom-icon evidence covers full quotas, +while media also covers duplicate chunks and interrupted files; profile and +backup evidence covers every atomic-replacement phase and fresh-secret +initialization. The complete path-by-path disposition is the [atomic transition inventory](../34-atomic-transition-inventory.md). This is not full ADR acceptance. The current linked-device Alpha implementation -uses the authority design that ADR-0026 must replace and retains quarantined -multi-write link/sync paths. The current automatic-contact flow remains outside -stable-v1 pending ADR-0030. Interrupted initial profile creation can leave an -unusable partial path, mailbox-v1 cannot yet acknowledge leased relay custody -after endpoint commit, and live call state remains intentionally process-local. -Independent review and supported-platform sudden-power-loss qualification are -also absent. These gaps keep this ADR Proposed and prevent the implemented -matrix from being presented as universal protocol atomicity. +still uses the authority design that ADR-0026 must replace. The pre-C2 contact +manifest bridge and current automatic-contact flow remain quarantined outside +stable-v1 pending ADR-0030. Mailbox-v1 cannot yet acknowledge leased relay +custody after endpoint commit, and live call state remains intentionally +process-local. Independent review and supported-platform sudden-power-loss +qualification are also absent. These gaps keep this ADR Proposed and prevent +the implemented matrix from being presented as universal protocol atomicity. ## Alternatives considered