lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit c10b5663af892b09e0f0fd49d7cf4d37153a4905
parent 5139f6169764a01a0c4ec149f9af6a8726afa3b9
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 07:15:27 +0000

simplex: add encrypted app store crate

- add SQLCipher-backed SimpleX app persistence with host-vault key loading
- create forward-only milestone schema for profiles, contacts, queues, chat, inbound, outbox, and unsupported events
- expose typed repository operations and redacted open diagnostics
- cover encrypted bytes, reopen, fail-closed key handling, foreign keys, indexes, and dedupe tests

Diffstat:
MCargo.lock | 16++++++++++++++++
MCargo.toml | 2++
Acrates/simplex_app_store/Cargo.toml | 37+++++++++++++++++++++++++++++++++++++
Acrates/simplex_app_store/src/error.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex_app_store/src/lib.rs | 35+++++++++++++++++++++++++++++++++++
Acrates/simplex_app_store/src/model.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/simplex_app_store/src/store.rs | 1112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1391 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -2866,6 +2866,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] @@ -4620,6 +4621,21 @@ dependencies = [ ] [[package]] +name = "radroots_simplex_app_store" +version = "0.1.0-alpha.2" +dependencies = [ + "getrandom 0.2.17", + "hex", + "radroots_secret_vault", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "zeroize", +] + +[[package]] name = "radroots_simplex_chat_proto" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "crates/relay_transport", "crates/runtime", "crates/secret_vault", + "crates/simplex_app_store", "crates/simplex_agent_proto", "crates/simplex_agent_runtime", "crates/simplex_agent_store", @@ -96,6 +97,7 @@ radroots_trade = { path = "crates/trade", version = "0.1.0-alpha.2", default-fea radroots_types = { path = "crates/types", version = "0.1.0-alpha.2", default-features = false } radroots_protected_store = { path = "crates/protected_store", version = "0.1.0-alpha.2", default-features = false } radroots_secret_vault = { path = "crates/secret_vault", version = "0.1.0-alpha.2", default-features = false } +radroots_simplex_app_store = { path = "crates/simplex_app_store", version = "0.1.0-alpha.2", default-features = false } radroots_sp1_guest_trade = { path = "crates/sp1_guest_trade", version = "0.1.0-alpha.2", default-features = false } radroots_sp1_host_trade = { path = "crates/sp1_host_trade", version = "0.1.0-alpha.2", default-features = false } diff --git a/crates/simplex_app_store/Cargo.toml b/crates/simplex_app_store/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "radroots_simplex_app_store" +publish = false +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "Encrypted SimpleX app persistence for Radroots runtimes" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_simplex_app_store" + +[features] +default = ["std", "sqlcipher", "os-keyring"] +std = [ + "radroots_secret_vault/std", + "serde/std", + "serde_json/std", + "zeroize/std", +] +sqlcipher = ["std", "dep:rusqlite"] +os-keyring = ["std", "radroots_secret_vault/os-keyring"] + +[dependencies] +getrandom = { workspace = true } +hex = { workspace = true } +radroots_secret_vault = { workspace = true, default-features = false } +rusqlite = { workspace = true, optional = true, features = ["bundled-sqlcipher-vendored-openssl"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +zeroize = { workspace = true } + +[dev-dependencies] +radroots_secret_vault = { workspace = true, features = ["memory-vault"] } +tempfile = { workspace = true } diff --git a/crates/simplex_app_store/src/error.rs b/crates/simplex_app_store/src/error.rs @@ -0,0 +1,59 @@ +use alloc::string::String; +use core::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsSimplexAppStoreError { + SecretVault(String), + MissingDatabaseKey, + InvalidDatabaseKey(String), + EncryptionUnavailable, + EncryptionKeyRejected, + Schema(String), + Sqlite(String), + Io(String), +} + +impl fmt::Display for RadrootsSimplexAppStoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SecretVault(message) => { + write!(formatter, "SimpleX app secret-vault error: {message}") + } + Self::MissingDatabaseKey => { + write!( + formatter, + "SimpleX app database key is missing from host secret storage" + ) + } + Self::InvalidDatabaseKey(message) => { + write!(formatter, "SimpleX app database key is invalid: {message}") + } + Self::EncryptionUnavailable => { + write!(formatter, "SimpleX app store encryption is unavailable") + } + Self::EncryptionKeyRejected => { + write!(formatter, "SimpleX app store encryption key was rejected") + } + Self::Schema(message) => write!(formatter, "SimpleX app store schema error: {message}"), + Self::Sqlite(message) => write!(formatter, "SimpleX app sqlite error: {message}"), + Self::Io(message) => write!(formatter, "SimpleX app store io error: {message}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsSimplexAppStoreError {} + +#[cfg(feature = "std")] +impl From<radroots_secret_vault::RadrootsSecretVaultAccessError> for RadrootsSimplexAppStoreError { + fn from(value: radroots_secret_vault::RadrootsSecretVaultAccessError) -> Self { + Self::SecretVault(value.to_string()) + } +} + +#[cfg(all(feature = "std", feature = "sqlcipher"))] +impl From<rusqlite::Error> for RadrootsSimplexAppStoreError { + fn from(value: rusqlite::Error) -> Self { + Self::Sqlite(value.to_string()) + } +} diff --git a/crates/simplex_app_store/src/lib.rs b/crates/simplex_app_store/src/lib.rs @@ -0,0 +1,35 @@ +#![forbid(unsafe_code)] +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; +#[cfg(feature = "std")] +extern crate std; + +pub mod error; +pub mod model; +#[cfg(all(feature = "std", feature = "sqlcipher"))] +pub mod store; + +pub mod prelude { + pub use crate::error::RadrootsSimplexAppStoreError; + pub use crate::model::{ + RadrootsSimplexAppChatDirection, RadrootsSimplexAppChatItem, RadrootsSimplexAppConnection, + RadrootsSimplexAppContact, RadrootsSimplexAppConversation, RadrootsSimplexAppDiagnostics, + RadrootsSimplexAppInboundMessageLogEntry, RadrootsSimplexAppOutboxMessage, + RadrootsSimplexAppProfile, RadrootsSimplexAppQueueEndpoint, + RadrootsSimplexAppUnsupportedProtocolEvent, + }; + #[cfg(all(feature = "std", feature = "sqlcipher"))] + pub use crate::store::RadrootsSimplexAppStore; +} + +pub use error::RadrootsSimplexAppStoreError; +pub use model::{ + RadrootsSimplexAppChatDirection, RadrootsSimplexAppChatItem, RadrootsSimplexAppConnection, + RadrootsSimplexAppContact, RadrootsSimplexAppConversation, RadrootsSimplexAppDiagnostics, + RadrootsSimplexAppInboundMessageLogEntry, RadrootsSimplexAppOutboxMessage, + RadrootsSimplexAppProfile, RadrootsSimplexAppQueueEndpoint, + RadrootsSimplexAppUnsupportedProtocolEvent, +}; +#[cfg(all(feature = "std", feature = "sqlcipher"))] +pub use store::RadrootsSimplexAppStore; diff --git a/crates/simplex_app_store/src/model.rs b/crates/simplex_app_store/src/model.rs @@ -0,0 +1,130 @@ +use alloc::string::String; +use alloc::vec::Vec; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppProfile { + pub profile_id: String, + pub display_name: String, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppContact { + pub contact_id: String, + pub profile_id: String, + pub display_name: String, + pub lifecycle: String, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppConnection { + pub connection_id: String, + pub profile_id: String, + pub contact_id: Option<String>, + pub state: String, + pub agent_connection_id: Option<String>, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppQueueEndpoint { + pub queue_endpoint_id: String, + pub connection_id: String, + pub role: String, + pub server: String, + pub sender_id: Vec<u8>, + pub status: String, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppConversation { + pub conversation_id: String, + pub profile_id: String, + pub contact_id: Option<String>, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RadrootsSimplexAppChatDirection { + Inbound, + Outbound, + System, +} + +impl RadrootsSimplexAppChatDirection { + pub fn as_str(self) -> &'static str { + match self { + Self::Inbound => "inbound", + Self::Outbound => "outbound", + Self::System => "system", + } + } + + pub fn parse(value: &str) -> Result<Self, String> { + match value { + "inbound" => Ok(Self::Inbound), + "outbound" => Ok(Self::Outbound), + "system" => Ok(Self::System), + other => Err(alloc::format!("unknown chat direction `{other}`")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppChatItem { + pub chat_item_id: String, + pub conversation_id: String, + pub logical_order: i64, + pub direction: RadrootsSimplexAppChatDirection, + pub body: String, + pub delivery_status: String, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppInboundMessageLogEntry { + pub inbound_id: String, + pub connection_id: String, + pub broker_message_id_hash: Vec<u8>, + pub inbound_sequence: Option<i64>, + pub message_hash: Vec<u8>, + pub ack_status: String, + pub received_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppOutboxMessage { + pub outbox_id: String, + pub connection_id: String, + pub conversation_id: Option<String>, + pub body: String, + pub status: String, + pub retry_after_unix: Option<i64>, + pub created_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsSimplexAppUnsupportedProtocolEvent { + pub event_id: String, + pub connection_id: Option<String>, + pub event_kind: String, + pub payload_json: String, + pub status: String, + pub received_at_unix: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsSimplexAppDiagnostics { + pub encrypted: bool, + pub cipher: String, + pub schema_version: u32, + pub migration_count: usize, + pub foreign_keys_enabled: bool, + pub wal_enabled: bool, + pub key_source: String, + pub key_slot_digest: String, +} diff --git a/crates/simplex_app_store/src/store.rs b/crates/simplex_app_store/src/store.rs @@ -0,0 +1,1112 @@ +use crate::error::RadrootsSimplexAppStoreError; +use crate::model::{ + RadrootsSimplexAppChatDirection, RadrootsSimplexAppChatItem, RadrootsSimplexAppConnection, + RadrootsSimplexAppContact, RadrootsSimplexAppConversation, RadrootsSimplexAppDiagnostics, + RadrootsSimplexAppInboundMessageLogEntry, RadrootsSimplexAppOutboxMessage, + RadrootsSimplexAppProfile, RadrootsSimplexAppQueueEndpoint, + RadrootsSimplexAppUnsupportedProtocolEvent, +}; +use alloc::format; +use alloc::string::String; +use alloc::sync::Arc; +use alloc::vec::Vec; +use getrandom::getrandom; +use radroots_secret_vault::RadrootsSecretVault; +#[cfg(feature = "os-keyring")] +use radroots_secret_vault::RadrootsSecretVaultOsKeyring; +use rusqlite::{Connection, OpenFlags, OptionalExtension, Row, Transaction, params}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use zeroize::Zeroize; + +const CURRENT_SCHEMA_VERSION: i64 = 1; +const DEFAULT_KEYCHAIN_SERVICE: &str = "org.radroots.simplex.app-store"; +const DATABASE_KEY_BYTES: usize = 32; + +pub struct RadrootsSimplexAppStore { + connection: Connection, + diagnostics: RadrootsSimplexAppDiagnostics, +} + +impl RadrootsSimplexAppStore { + #[cfg(feature = "os-keyring")] + pub fn open_keychain_backed( + path: impl AsRef<Path>, + ) -> Result<Self, RadrootsSimplexAppStoreError> { + let path = path.as_ref(); + let key_slot = derived_key_slot(path); + Self::open_with_vault( + path, + Arc::new(RadrootsSecretVaultOsKeyring::new(DEFAULT_KEYCHAIN_SERVICE)), + key_slot, + "host_vault", + ) + } + + pub fn open_with_vault( + path: impl AsRef<Path>, + vault: Arc<dyn RadrootsSecretVault>, + key_slot: impl Into<String>, + key_source: impl Into<String>, + ) -> Result<Self, RadrootsSimplexAppStoreError> { + let path = path.as_ref(); + let key_slot = key_slot.into(); + let key_source = key_source.into(); + let existed = path.exists(); + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).map_err(|error| { + RadrootsSimplexAppStoreError::Io(format!( + "failed to create SimpleX app store directory: {error}" + )) + })?; + } + + let mut key_hex = load_or_create_database_key(vault.as_ref(), &key_slot, existed)?; + let key_slot_digest = key_slot_digest(&key_slot); + let mut connection = open_keyed_connection(path, &key_hex)?; + key_hex.zeroize(); + let cipher = verify_encryption(&connection)?; + configure_connection(&connection)?; + migrate(&mut connection, &key_slot_digest, &key_source)?; + verify_metadata(&connection, &key_slot_digest)?; + let diagnostics = diagnostics_for(&connection, cipher, key_source, key_slot_digest)?; + Ok(Self { + connection, + diagnostics, + }) + } + + pub fn diagnostics(&self) -> &RadrootsSimplexAppDiagnostics { + &self.diagnostics + } + + pub fn upsert_profile( + &self, + profile: &RadrootsSimplexAppProfile, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO profiles (profile_id, display_name, created_at_unix) + VALUES (?1, ?2, ?3) + ON CONFLICT(profile_id) DO UPDATE SET display_name = excluded.display_name", + params![ + profile.profile_id, + profile.display_name, + profile.created_at_unix + ], + )?; + Ok(()) + } + + pub fn get_profile( + &self, + profile_id: &str, + ) -> Result<Option<RadrootsSimplexAppProfile>, RadrootsSimplexAppStoreError> { + self.connection + .query_row( + "SELECT profile_id, display_name, created_at_unix FROM profiles WHERE profile_id = ?1", + params![profile_id], + profile_from_row, + ) + .optional() + .map_err(Into::into) + } + + pub fn list_profiles( + &self, + ) -> Result<Vec<RadrootsSimplexAppProfile>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT profile_id, display_name, created_at_unix FROM profiles ORDER BY profile_id", + )?; + collect_rows(statement.query_map([], profile_from_row)?) + } + + pub fn upsert_contact( + &self, + contact: &RadrootsSimplexAppContact, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO contacts (contact_id, profile_id, display_name, lifecycle, created_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(contact_id) DO UPDATE SET + display_name = excluded.display_name, + lifecycle = excluded.lifecycle", + params![ + contact.contact_id, + contact.profile_id, + contact.display_name, + contact.lifecycle, + contact.created_at_unix + ], + )?; + Ok(()) + } + + pub fn list_contacts( + &self, + ) -> Result<Vec<RadrootsSimplexAppContact>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT contact_id, profile_id, display_name, lifecycle, created_at_unix + FROM contacts ORDER BY contact_id", + )?; + collect_rows(statement.query_map([], contact_from_row)?) + } + + pub fn upsert_connection( + &self, + connection: &RadrootsSimplexAppConnection, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO connections + (connection_id, profile_id, contact_id, state, agent_connection_id, created_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(connection_id) DO UPDATE SET + contact_id = excluded.contact_id, + state = excluded.state, + agent_connection_id = excluded.agent_connection_id", + params![ + connection.connection_id, + connection.profile_id, + connection.contact_id, + connection.state, + connection.agent_connection_id, + connection.created_at_unix + ], + )?; + Ok(()) + } + + pub fn list_connections_by_state( + &self, + state: &str, + ) -> Result<Vec<RadrootsSimplexAppConnection>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT connection_id, profile_id, contact_id, state, agent_connection_id, created_at_unix + FROM connections WHERE state = ?1 ORDER BY connection_id", + )?; + collect_rows(statement.query_map(params![state], connection_from_row)?) + } + + pub fn upsert_queue_endpoint( + &self, + queue: &RadrootsSimplexAppQueueEndpoint, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO queue_endpoints + (queue_endpoint_id, connection_id, role, server, sender_id, status, created_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(queue_endpoint_id) DO UPDATE SET + role = excluded.role, + server = excluded.server, + sender_id = excluded.sender_id, + status = excluded.status", + params![ + queue.queue_endpoint_id, + queue.connection_id, + queue.role, + queue.server, + queue.sender_id, + queue.status, + queue.created_at_unix + ], + )?; + Ok(()) + } + + pub fn list_queues_by_status( + &self, + status: &str, + ) -> Result<Vec<RadrootsSimplexAppQueueEndpoint>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT queue_endpoint_id, connection_id, role, server, sender_id, status, created_at_unix + FROM queue_endpoints WHERE status = ?1 ORDER BY queue_endpoint_id", + )?; + collect_rows(statement.query_map(params![status], queue_endpoint_from_row)?) + } + + pub fn upsert_conversation( + &self, + conversation: &RadrootsSimplexAppConversation, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO conversations (conversation_id, profile_id, contact_id, created_at_unix) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(conversation_id) DO UPDATE SET contact_id = excluded.contact_id", + params![ + conversation.conversation_id, + conversation.profile_id, + conversation.contact_id, + conversation.created_at_unix + ], + )?; + Ok(()) + } + + pub fn append_chat_item( + &self, + item: &RadrootsSimplexAppChatItem, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO chat_items + (chat_item_id, conversation_id, logical_order, direction, body, delivery_status, created_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + item.chat_item_id, + item.conversation_id, + item.logical_order, + item.direction.as_str(), + item.body, + item.delivery_status, + item.created_at_unix + ], + )?; + Ok(()) + } + + pub fn chat_page( + &self, + conversation_id: &str, + limit: usize, + ) -> Result<Vec<RadrootsSimplexAppChatItem>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT chat_item_id, conversation_id, logical_order, direction, body, delivery_status, created_at_unix + FROM chat_items + WHERE conversation_id = ?1 + ORDER BY logical_order DESC, chat_item_id DESC + LIMIT ?2", + )?; + collect_rows( + statement.query_map(params![conversation_id, limit as i64], chat_item_from_row)?, + ) + } + + pub fn record_inbound_message( + &self, + entry: &RadrootsSimplexAppInboundMessageLogEntry, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO inbound_message_log + (inbound_id, connection_id, broker_message_id_hash, inbound_sequence, message_hash, ack_status, received_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + entry.inbound_id, + entry.connection_id, + entry.broker_message_id_hash, + entry.inbound_sequence, + entry.message_hash, + entry.ack_status, + entry.received_at_unix + ], + )?; + Ok(()) + } + + pub fn pending_ack_messages( + &self, + ) -> Result<Vec<RadrootsSimplexAppInboundMessageLogEntry>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT inbound_id, connection_id, broker_message_id_hash, inbound_sequence, message_hash, ack_status, received_at_unix + FROM inbound_message_log + WHERE ack_status = 'pending' + ORDER BY received_at_unix, inbound_id", + )?; + collect_rows(statement.query_map([], inbound_message_from_row)?) + } + + pub fn enqueue_outbox_message( + &self, + message: &RadrootsSimplexAppOutboxMessage, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO outbox_messages + (outbox_id, connection_id, conversation_id, body, status, retry_after_unix, created_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + message.outbox_id, + message.connection_id, + message.conversation_id, + message.body, + message.status, + message.retry_after_unix, + message.created_at_unix + ], + )?; + Ok(()) + } + + pub fn pending_outbox_messages( + &self, + ) -> Result<Vec<RadrootsSimplexAppOutboxMessage>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT outbox_id, connection_id, conversation_id, body, status, retry_after_unix, created_at_unix + FROM outbox_messages + WHERE status IN ('pending', 'retryable') + ORDER BY created_at_unix, outbox_id", + )?; + collect_rows(statement.query_map([], outbox_message_from_row)?) + } + + pub fn record_unsupported_protocol_event( + &self, + event: &RadrootsSimplexAppUnsupportedProtocolEvent, + ) -> Result<(), RadrootsSimplexAppStoreError> { + self.connection.execute( + "INSERT INTO unsupported_protocol_events + (event_id, connection_id, event_kind, payload_json, status, received_at_unix) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + event.event_id, + event.connection_id, + event.event_kind, + event.payload_json, + event.status, + event.received_at_unix + ], + )?; + Ok(()) + } + + pub fn list_unsupported_protocol_events( + &self, + ) -> Result<Vec<RadrootsSimplexAppUnsupportedProtocolEvent>, RadrootsSimplexAppStoreError> { + let mut statement = self.connection.prepare( + "SELECT event_id, connection_id, event_kind, payload_json, status, received_at_unix + FROM unsupported_protocol_events ORDER BY received_at_unix, event_id", + )?; + collect_rows(statement.query_map([], unsupported_event_from_row)?) + } +} + +fn load_or_create_database_key( + vault: &dyn RadrootsSecretVault, + key_slot: &str, + database_exists: bool, +) -> Result<String, RadrootsSimplexAppStoreError> { + match vault.load_secret(key_slot)? { + Some(secret) => validate_database_key(secret), + None if database_exists => Err(RadrootsSimplexAppStoreError::MissingDatabaseKey), + None => { + let key = generate_database_key_hex()?; + vault.store_secret(key_slot, &key)?; + Ok(key) + } + } +} + +fn generate_database_key_hex() -> Result<String, RadrootsSimplexAppStoreError> { + let mut key = [0_u8; DATABASE_KEY_BYTES]; + getrandom(&mut key).map_err(|_| { + RadrootsSimplexAppStoreError::InvalidDatabaseKey("entropy unavailable".into()) + })?; + let hex = hex::encode(key); + key.zeroize(); + Ok(hex) +} + +fn validate_database_key(secret: String) -> Result<String, RadrootsSimplexAppStoreError> { + if secret.len() != DATABASE_KEY_BYTES * 2 { + return Err(RadrootsSimplexAppStoreError::InvalidDatabaseKey( + "expected 32-byte hex key".into(), + )); + } + if !secret.as_bytes().iter().all(u8::is_ascii_hexdigit) { + return Err(RadrootsSimplexAppStoreError::InvalidDatabaseKey( + "key is not hex encoded".into(), + )); + } + Ok(secret) +} + +fn open_keyed_connection( + path: &Path, + key_hex: &str, +) -> Result<Connection, RadrootsSimplexAppStoreError> { + let connection = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_FULL_MUTEX, + )?; + connection.busy_timeout(Duration::from_secs(5))?; + connection.execute_batch(&format!("PRAGMA key = \"x'{key_hex}'\";"))?; + match connection.query_row("SELECT count(*) FROM sqlite_schema", [], |_| Ok(())) { + Ok(()) => Ok(connection), + Err(_) => Err(RadrootsSimplexAppStoreError::EncryptionKeyRejected), + } +} + +fn verify_encryption(connection: &Connection) -> Result<String, RadrootsSimplexAppStoreError> { + let cipher = connection + .query_row("PRAGMA cipher_version", [], |row| row.get::<_, String>(0)) + .optional()? + .ok_or(RadrootsSimplexAppStoreError::EncryptionUnavailable)?; + if cipher.trim().is_empty() { + return Err(RadrootsSimplexAppStoreError::EncryptionUnavailable); + } + Ok(cipher) +} + +fn configure_connection(connection: &Connection) -> Result<(), RadrootsSimplexAppStoreError> { + connection.pragma_update(None, "foreign_keys", true)?; + let foreign_keys: i64 = + connection.pragma_query_value(None, "foreign_keys", |row| row.get(0))?; + if foreign_keys != 1 { + return Err(RadrootsSimplexAppStoreError::Schema( + "foreign keys did not enable".into(), + )); + } + let journal_mode: String = + connection.pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))?; + if !journal_mode.eq_ignore_ascii_case("wal") { + return Err(RadrootsSimplexAppStoreError::Schema(format!( + "WAL journal mode unavailable: {journal_mode}" + ))); + } + connection.pragma_update(None, "synchronous", "NORMAL")?; + Ok(()) +} + +fn migrate( + connection: &mut Connection, + key_slot_digest: &str, + key_source: &str, +) -> Result<(), RadrootsSimplexAppStoreError> { + let user_version: i64 = + connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + if user_version > CURRENT_SCHEMA_VERSION { + return Err(RadrootsSimplexAppStoreError::Schema(format!( + "unsupported future schema version `{user_version}`" + ))); + } + if user_version == CURRENT_SCHEMA_VERSION { + return Ok(()); + } + if user_version != 0 { + return Err(RadrootsSimplexAppStoreError::Schema(format!( + "unsupported schema version `{user_version}`" + ))); + } + + let transaction = connection.transaction()?; + apply_schema_v1(&transaction)?; + transaction.execute( + "INSERT INTO encryption_metadata + (id, key_slot_digest, key_source, cipher, created_at_unix) + VALUES (1, ?1, ?2, 'sqlcipher', ?3)", + params![key_slot_digest, key_source, now_unix_secs()], + )?; + transaction.execute( + "INSERT INTO simplex_schema_migrations (version, name, applied_at_unix) + VALUES (1, 'initial-simplex-app-store', ?1)", + params![now_unix_secs()], + )?; + transaction.pragma_update(None, "user_version", CURRENT_SCHEMA_VERSION)?; + transaction.commit()?; + Ok(()) +} + +fn apply_schema_v1(transaction: &Transaction<'_>) -> Result<(), RadrootsSimplexAppStoreError> { + transaction.execute_batch( + " + CREATE TABLE encryption_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + key_slot_digest TEXT NOT NULL, + key_source TEXT NOT NULL, + cipher TEXT NOT NULL, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE simplex_schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at_unix INTEGER NOT NULL + ); + + CREATE TABLE profiles ( + profile_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE contacts ( + contact_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id) ON DELETE CASCADE, + display_name TEXT NOT NULL, + lifecycle TEXT NOT NULL, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE connections ( + connection_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id) ON DELETE CASCADE, + contact_id TEXT REFERENCES contacts(contact_id) ON DELETE SET NULL, + state TEXT NOT NULL, + agent_connection_id TEXT, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE queue_endpoints ( + queue_endpoint_id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL REFERENCES connections(connection_id) ON DELETE CASCADE, + role TEXT NOT NULL, + server TEXT NOT NULL, + sender_id BLOB NOT NULL, + status TEXT NOT NULL, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE conversations ( + conversation_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id) ON DELETE CASCADE, + contact_id TEXT REFERENCES contacts(contact_id) ON DELETE SET NULL, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE chat_items ( + chat_item_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE, + logical_order INTEGER NOT NULL, + direction TEXT NOT NULL, + body TEXT NOT NULL, + delivery_status TEXT NOT NULL, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE inbound_message_log ( + inbound_id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL REFERENCES connections(connection_id) ON DELETE CASCADE, + broker_message_id_hash BLOB NOT NULL, + inbound_sequence INTEGER, + message_hash BLOB NOT NULL, + ack_status TEXT NOT NULL, + received_at_unix INTEGER NOT NULL, + UNIQUE(connection_id, broker_message_id_hash) + ); + + CREATE TABLE outbox_messages ( + outbox_id TEXT PRIMARY KEY, + connection_id TEXT NOT NULL REFERENCES connections(connection_id) ON DELETE CASCADE, + conversation_id TEXT REFERENCES conversations(conversation_id) ON DELETE SET NULL, + body TEXT NOT NULL, + status TEXT NOT NULL, + retry_after_unix INTEGER, + created_at_unix INTEGER NOT NULL + ); + + CREATE TABLE unsupported_protocol_events ( + event_id TEXT PRIMARY KEY, + connection_id TEXT REFERENCES connections(connection_id) ON DELETE SET NULL, + event_kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL, + received_at_unix INTEGER NOT NULL + ); + + CREATE INDEX chat_items_page_idx + ON chat_items(conversation_id, logical_order DESC, chat_item_id DESC); + CREATE UNIQUE INDEX inbound_message_log_sequence_hash_idx + ON inbound_message_log(connection_id, inbound_sequence, message_hash) + WHERE inbound_sequence IS NOT NULL; + CREATE INDEX inbound_message_log_pending_ack_idx + ON inbound_message_log(connection_id, inbound_id) + WHERE ack_status = 'pending'; + CREATE INDEX outbox_messages_pending_retryable_idx + ON outbox_messages(connection_id, outbox_id) + WHERE status IN ('pending', 'retryable'); + CREATE INDEX connections_state_idx ON connections(state); + CREATE INDEX queue_endpoints_status_idx ON queue_endpoints(status); + CREATE INDEX contacts_lifecycle_idx ON contacts(lifecycle); + ", + )?; + Ok(()) +} + +fn verify_metadata( + connection: &Connection, + expected_key_slot_digest: &str, +) -> Result<(), RadrootsSimplexAppStoreError> { + let actual_key_slot_digest: String = connection.query_row( + "SELECT key_slot_digest FROM encryption_metadata WHERE id = 1", + [], + |row| row.get(0), + )?; + if actual_key_slot_digest != expected_key_slot_digest { + return Err(RadrootsSimplexAppStoreError::EncryptionKeyRejected); + } + Ok(()) +} + +fn diagnostics_for( + connection: &Connection, + cipher: String, + key_source: String, + key_slot_digest: String, +) -> Result<RadrootsSimplexAppDiagnostics, RadrootsSimplexAppStoreError> { + let schema_version: i64 = + connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + let migration_count: i64 = connection.query_row( + "SELECT count(*) FROM simplex_schema_migrations", + [], + |row| row.get(0), + )?; + let foreign_keys: i64 = + connection.pragma_query_value(None, "foreign_keys", |row| row.get(0))?; + let journal_mode: String = + connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?; + Ok(RadrootsSimplexAppDiagnostics { + encrypted: true, + cipher, + schema_version: schema_version as u32, + migration_count: migration_count as usize, + foreign_keys_enabled: foreign_keys == 1, + wal_enabled: journal_mode.eq_ignore_ascii_case("wal"), + key_source, + key_slot_digest, + }) +} + +fn key_slot_digest(key_slot: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(key_slot.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn derived_key_slot(path: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(path.as_os_str().as_encoded_bytes()); + format!( + "radroots_simplex_app_store_{}", + hex::encode(hasher.finalize()) + ) +} + +fn now_unix_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + +fn collect_rows<T>( + rows: rusqlite::MappedRows<'_, impl FnMut(&Row<'_>) -> rusqlite::Result<T>>, +) -> Result<Vec<T>, RadrootsSimplexAppStoreError> { + rows.collect::<Result<Vec<_>, _>>().map_err(Into::into) +} + +fn profile_from_row(row: &Row<'_>) -> rusqlite::Result<RadrootsSimplexAppProfile> { + Ok(RadrootsSimplexAppProfile { + profile_id: row.get(0)?, + display_name: row.get(1)?, + created_at_unix: row.get(2)?, + }) +} + +fn contact_from_row(row: &Row<'_>) -> rusqlite::Result<RadrootsSimplexAppContact> { + Ok(RadrootsSimplexAppContact { + contact_id: row.get(0)?, + profile_id: row.get(1)?, + display_name: row.get(2)?, + lifecycle: row.get(3)?, + created_at_unix: row.get(4)?, + }) +} + +fn connection_from_row(row: &Row<'_>) -> rusqlite::Result<RadrootsSimplexAppConnection> { + Ok(RadrootsSimplexAppConnection { + connection_id: row.get(0)?, + profile_id: row.get(1)?, + contact_id: row.get(2)?, + state: row.get(3)?, + agent_connection_id: row.get(4)?, + created_at_unix: row.get(5)?, + }) +} + +fn queue_endpoint_from_row(row: &Row<'_>) -> rusqlite::Result<RadrootsSimplexAppQueueEndpoint> { + Ok(RadrootsSimplexAppQueueEndpoint { + queue_endpoint_id: row.get(0)?, + connection_id: row.get(1)?, + role: row.get(2)?, + server: row.get(3)?, + sender_id: row.get(4)?, + status: row.get(5)?, + created_at_unix: row.get(6)?, + }) +} + +fn chat_item_from_row(row: &Row<'_>) -> rusqlite::Result<RadrootsSimplexAppChatItem> { + let direction: String = row.get(3)?; + Ok(RadrootsSimplexAppChatItem { + chat_item_id: row.get(0)?, + conversation_id: row.get(1)?, + logical_order: row.get(2)?, + direction: RadrootsSimplexAppChatDirection::parse(&direction) + .map_err(|error| rusqlite::Error::ToSqlConversionFailure(error.into()))?, + body: row.get(4)?, + delivery_status: row.get(5)?, + created_at_unix: row.get(6)?, + }) +} + +fn inbound_message_from_row( + row: &Row<'_>, +) -> rusqlite::Result<RadrootsSimplexAppInboundMessageLogEntry> { + Ok(RadrootsSimplexAppInboundMessageLogEntry { + inbound_id: row.get(0)?, + connection_id: row.get(1)?, + broker_message_id_hash: row.get(2)?, + inbound_sequence: row.get(3)?, + message_hash: row.get(4)?, + ack_status: row.get(5)?, + received_at_unix: row.get(6)?, + }) +} + +fn outbox_message_from_row(row: &Row<'_>) -> rusqlite::Result<RadrootsSimplexAppOutboxMessage> { + Ok(RadrootsSimplexAppOutboxMessage { + outbox_id: row.get(0)?, + connection_id: row.get(1)?, + conversation_id: row.get(2)?, + body: row.get(3)?, + status: row.get(4)?, + retry_after_unix: row.get(5)?, + created_at_unix: row.get(6)?, + }) +} + +fn unsupported_event_from_row( + row: &Row<'_>, +) -> rusqlite::Result<RadrootsSimplexAppUnsupportedProtocolEvent> { + Ok(RadrootsSimplexAppUnsupportedProtocolEvent { + event_id: row.get(0)?, + connection_id: row.get(1)?, + event_kind: row.get(2)?, + payload_json: row.get(3)?, + status: row.get(4)?, + received_at_unix: row.get(5)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultMemory}; + use std::sync::Arc; + + fn memory_store( + path: &Path, + vault: Arc<RadrootsSecretVaultMemory>, + ) -> Result<RadrootsSimplexAppStore, RadrootsSimplexAppStoreError> { + RadrootsSimplexAppStore::open_with_vault(path, vault, "test-simplex-app-store", "memory") + } + + fn profile() -> RadrootsSimplexAppProfile { + RadrootsSimplexAppProfile { + profile_id: "profile-1".into(), + display_name: "Local Profile".into(), + created_at_unix: 1, + } + } + + fn contact() -> RadrootsSimplexAppContact { + RadrootsSimplexAppContact { + contact_id: "contact-1".into(), + profile_id: "profile-1".into(), + display_name: "Phone Contact".into(), + lifecycle: "active".into(), + created_at_unix: 2, + } + } + + fn connection() -> RadrootsSimplexAppConnection { + RadrootsSimplexAppConnection { + connection_id: "connection-1".into(), + profile_id: "profile-1".into(), + contact_id: Some("contact-1".into()), + state: "connected".into(), + agent_connection_id: Some("agent-connection-1".into()), + created_at_unix: 3, + } + } + + fn queue() -> RadrootsSimplexAppQueueEndpoint { + RadrootsSimplexAppQueueEndpoint { + queue_endpoint_id: "queue-1".into(), + connection_id: "connection-1".into(), + role: "receive".into(), + server: "smp.example".into(), + sender_id: b"sender-id".to_vec(), + status: "active".into(), + created_at_unix: 4, + } + } + + fn conversation() -> RadrootsSimplexAppConversation { + RadrootsSimplexAppConversation { + conversation_id: "conversation-1".into(), + profile_id: "profile-1".into(), + contact_id: Some("contact-1".into()), + created_at_unix: 5, + } + } + + fn seed_store(store: &RadrootsSimplexAppStore) { + store.upsert_profile(&profile()).expect("profile"); + store.upsert_contact(&contact()).expect("contact"); + store.upsert_connection(&connection()).expect("connection"); + store.upsert_queue_endpoint(&queue()).expect("queue"); + store + .upsert_conversation(&conversation()) + .expect("conversation"); + } + + #[test] + fn empty_store_initializes_encrypted_schema() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault).expect("store"); + + let diagnostics = store.diagnostics(); + assert!(diagnostics.encrypted); + assert!(!diagnostics.cipher.is_empty()); + assert_eq!(diagnostics.schema_version, 1); + assert_eq!(diagnostics.migration_count, 1); + assert!(diagnostics.foreign_keys_enabled); + assert!(diagnostics.wal_enabled); + assert_eq!(diagnostics.key_source, "memory"); + assert_eq!(diagnostics.key_slot_digest.len(), 64); + } + + #[test] + fn typed_repositories_round_trip_and_indexes_support_queries() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault).expect("store"); + seed_store(&store); + + assert_eq!( + store.get_profile("profile-1").expect("profile"), + Some(profile()) + ); + assert_eq!(store.list_profiles().expect("profiles"), vec![profile()]); + assert_eq!(store.list_contacts().expect("contacts"), vec![contact()]); + assert_eq!( + store + .list_connections_by_state("connected") + .expect("connections"), + vec![connection()] + ); + assert_eq!( + store.list_queues_by_status("active").expect("queues"), + vec![queue()] + ); + + store + .append_chat_item(&RadrootsSimplexAppChatItem { + chat_item_id: "chat-1".into(), + conversation_id: "conversation-1".into(), + logical_order: 1, + direction: RadrootsSimplexAppChatDirection::Outbound, + body: "hello encrypted iPhone".into(), + delivery_status: "sent".into(), + created_at_unix: 6, + }) + .expect("chat 1"); + store + .append_chat_item(&RadrootsSimplexAppChatItem { + chat_item_id: "chat-2".into(), + conversation_id: "conversation-1".into(), + logical_order: 2, + direction: RadrootsSimplexAppChatDirection::Inbound, + body: "hello encrypted runtime".into(), + delivery_status: "received".into(), + created_at_unix: 7, + }) + .expect("chat 2"); + + let page = store.chat_page("conversation-1", 10).expect("page"); + assert_eq!(page[0].chat_item_id, "chat-2"); + assert_eq!(page[1].chat_item_id, "chat-1"); + + store + .record_inbound_message(&RadrootsSimplexAppInboundMessageLogEntry { + inbound_id: "inbound-1".into(), + connection_id: "connection-1".into(), + broker_message_id_hash: b"broker-hash".to_vec(), + inbound_sequence: Some(1), + message_hash: b"message-hash".to_vec(), + ack_status: "pending".into(), + received_at_unix: 8, + }) + .expect("inbound"); + assert_eq!( + store.pending_ack_messages().expect("pending ack")[0].inbound_id, + "inbound-1" + ); + + store + .enqueue_outbox_message(&RadrootsSimplexAppOutboxMessage { + outbox_id: "outbox-1".into(), + connection_id: "connection-1".into(), + conversation_id: Some("conversation-1".into()), + body: "queued plaintext before encryption".into(), + status: "retryable".into(), + retry_after_unix: Some(9), + created_at_unix: 9, + }) + .expect("outbox"); + assert_eq!( + store.pending_outbox_messages().expect("outbox")[0].outbox_id, + "outbox-1" + ); + + store + .record_unsupported_protocol_event(&RadrootsSimplexAppUnsupportedProtocolEvent { + event_id: "event-1".into(), + connection_id: Some("connection-1".into()), + event_kind: "future_event".into(), + payload_json: "{\"field\":\"value\"}".into(), + status: "stored".into(), + received_at_unix: 10, + }) + .expect("unsupported"); + assert_eq!( + store + .list_unsupported_protocol_events() + .expect("unsupported")[0] + .event_id, + "event-1" + ); + } + + #[test] + fn database_bytes_do_not_expose_message_or_profile_text() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault).expect("store"); + seed_store(&store); + store + .append_chat_item(&RadrootsSimplexAppChatItem { + chat_item_id: "chat-1".into(), + conversation_id: "conversation-1".into(), + logical_order: 1, + direction: RadrootsSimplexAppChatDirection::Outbound, + body: "plaintext should not appear in sqlite bytes".into(), + delivery_status: "sent".into(), + created_at_unix: 6, + }) + .expect("chat"); + drop(store); + + let raw = fs::read(&path).expect("read database"); + let raw_text = String::from_utf8_lossy(&raw); + assert!(!raw_text.contains("Local Profile")); + assert!(!raw_text.contains("plaintext should not appear")); + } + + #[test] + fn existing_store_reopens_with_same_vault_key() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault.clone()).expect("store"); + seed_store(&store); + drop(store); + + let reopened = memory_store(&path, vault).expect("reopen"); + assert_eq!( + reopened.get_profile("profile-1").expect("profile"), + Some(profile()) + ); + } + + #[test] + fn missing_key_for_existing_store_fails_closed() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault).expect("store"); + seed_store(&store); + drop(store); + + let missing_vault = Arc::new(RadrootsSecretVaultMemory::new()); + let error = memory_store(&path, missing_vault) + .err() + .expect("missing key error"); + assert_eq!(error, RadrootsSimplexAppStoreError::MissingDatabaseKey); + } + + #[test] + fn corrupt_key_fails_before_database_open() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + vault + .store_secret("test-simplex-app-store", "not-a-valid-key") + .expect("secret"); + + let error = memory_store(&path, vault).err().expect("invalid key error"); + assert!(matches!( + error, + RadrootsSimplexAppStoreError::InvalidDatabaseKey(_) + )); + } + + #[test] + fn wrong_key_for_existing_store_fails_closed() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault).expect("store"); + seed_store(&store); + drop(store); + + let wrong_vault = Arc::new(RadrootsSecretVaultMemory::new()); + wrong_vault + .store_secret( + "test-simplex-app-store", + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .expect("wrong"); + let error = memory_store(&path, wrong_vault) + .err() + .expect("wrong key error"); + assert_eq!(error, RadrootsSimplexAppStoreError::EncryptionKeyRejected); + } + + #[test] + fn foreign_keys_and_unique_dedupe_fail_closed() { + let temp = tempfile::tempdir().expect("temp"); + let path = temp.path().join("simplex.sqlite"); + let vault = Arc::new(RadrootsSecretVaultMemory::new()); + let store = memory_store(&path, vault).expect("store"); + let invalid_contact = RadrootsSimplexAppContact { + profile_id: "missing-profile".into(), + ..contact() + }; + assert!(store.upsert_contact(&invalid_contact).is_err()); + + seed_store(&store); + let inbound = RadrootsSimplexAppInboundMessageLogEntry { + inbound_id: "inbound-1".into(), + connection_id: "connection-1".into(), + broker_message_id_hash: b"dedupe".to_vec(), + inbound_sequence: Some(1), + message_hash: b"hash".to_vec(), + ack_status: "pending".into(), + received_at_unix: 8, + }; + store.record_inbound_message(&inbound).expect("inbound"); + let duplicate = RadrootsSimplexAppInboundMessageLogEntry { + inbound_id: "inbound-2".into(), + ..inbound + }; + assert!(store.record_inbound_message(&duplicate).is_err()); + } +}