Merge 6dc027124f47f0a6b783ccd17d4bc60f4a59cd67 into 1e373226aeaf8de5e84ddac85a8dd3c75f223c9f

This commit is contained in:
Jazz Turner-Baggs 2026-05-13 00:30:30 +00:00 committed by GitHub
commit 3f22db7657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 3497 additions and 382 deletions

172
Cargo.lock generated
View File

@ -278,7 +278,7 @@ dependencies = [
"arboard", "arboard",
"base64", "base64",
"clap", "clap",
"client", "client 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"crossterm 0.29.0", "crossterm 0.29.0",
"ratatui", "ratatui",
"serde", "serde",
@ -300,14 +300,26 @@ dependencies = [
name = "chat-sqlite" name = "chat-sqlite"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"crypto", "crypto 0.1.0",
"hex", "hex",
"rusqlite", "rusqlite",
"storage", "storage 0.1.0",
"tempfile", "tempfile",
"zeroize", "zeroize",
] ]
[[package]]
name = "chat-sqlite"
version = "0.1.0"
source = "git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa#39bf26756448dd16ddff89a6c0054f79236494aa"
dependencies = [
"crypto 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"hex",
"rusqlite",
"storage 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"zeroize",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.4.4" version = "0.4.4"
@ -363,18 +375,30 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
name = "client" name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chat-sqlite", "chat-sqlite 0.1.0",
"libchat", "components",
"libchat 0.1.0",
"logos-account",
"tempfile", "tempfile",
"thiserror", "thiserror",
] ]
[[package]]
name = "client"
version = "0.1.0"
source = "git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa#39bf26756448dd16ddff89a6c0054f79236494aa"
dependencies = [
"chat-sqlite 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"libchat 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"thiserror",
]
[[package]] [[package]]
name = "client-ffi" name = "client-ffi"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"client", "client 0.1.0",
"libchat", "libchat 0.1.0",
"safer-ffi", "safer-ffi",
] ]
@ -407,6 +431,16 @@ dependencies = [
"static_assertions", "static_assertions",
] ]
[[package]]
name = "components"
version = "0.1.0"
dependencies = [
"crypto 0.1.0",
"hex",
"libchat 0.1.0",
"storage 0.1.0",
]
[[package]] [[package]]
name = "const-oid" name = "const-oid"
version = "0.9.6" version = "0.9.6"
@ -433,6 +467,25 @@ dependencies = [
"rand 0.9.4", "rand 0.9.4",
] ]
[[package]]
name = "core_client"
version = "0.1.0"
dependencies = [
"blake2",
"chat-proto",
"chat-sqlite 0.1.0",
"crypto 0.1.0",
"hex",
"libchat 0.1.0",
"openmls",
"openmls_libcrux_crypto 0.3.1",
"openmls_memory_storage 0.5.0",
"openmls_traits 0.5.0",
"prost",
"storage 0.1.0",
"thiserror",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -540,6 +593,22 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "crypto"
version = "0.1.0"
source = "git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa#39bf26756448dd16ddff89a6c0054f79236494aa"
dependencies = [
"ed25519-dalek",
"generic-array 1.3.5",
"hkdf",
"rand_core 0.6.4",
"sha2",
"thiserror",
"x25519-dalek",
"xeddsa",
"zeroize",
]
[[package]] [[package]]
name = "crypto-bigint" name = "crypto-bigint"
version = "0.5.5" version = "0.5.5"
@ -714,18 +783,35 @@ version = "0.0.1"
dependencies = [ dependencies = [
"blake2", "blake2",
"chacha20poly1305", "chacha20poly1305",
"chat-sqlite", "chat-sqlite 0.1.0",
"hkdf", "hkdf",
"rand 0.9.4", "rand 0.9.4",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde", "serde",
"storage", "storage 0.1.0",
"tempfile", "tempfile",
"thiserror", "thiserror",
"x25519-dalek", "x25519-dalek",
"zeroize", "zeroize",
] ]
[[package]]
name = "double-ratchets"
version = "0.0.1"
source = "git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa#39bf26756448dd16ddff89a6c0054f79236494aa"
dependencies = [
"blake2",
"chacha20poly1305",
"hkdf",
"rand 0.9.4",
"rand_core 0.6.4",
"serde",
"storage 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"thiserror",
"x25519-dalek",
"zeroize",
]
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.16.9" version = "0.16.9"
@ -1313,6 +1399,19 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "integration_tests_core"
version = "0.1.0"
dependencies = [
"chat-sqlite 0.1.0",
"components",
"core_client",
"libchat 0.1.0",
"logos-account",
"storage 0.1.0",
"tempfile",
]
[[package]] [[package]]
name = "inventory" name = "inventory"
version = "0.3.24" version = "0.3.24"
@ -1397,17 +1496,42 @@ dependencies = [
"base64", "base64",
"blake2", "blake2",
"chat-proto", "chat-proto",
"chat-sqlite", "chat-sqlite 0.1.0",
"crypto", "components",
"double-ratchets", "crypto 0.1.0",
"double-ratchets 0.0.1",
"hex",
"openmls",
"openmls_libcrux_crypto 0.3.1",
"openmls_memory_storage 0.5.0",
"openmls_traits 0.5.0",
"prost",
"rand_core 0.6.4",
"safer-ffi",
"storage 0.1.0",
"tempfile",
"thiserror",
"x25519-dalek",
]
[[package]]
name = "libchat"
version = "0.1.0"
source = "git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa#39bf26756448dd16ddff89a6c0054f79236494aa"
dependencies = [
"base64",
"blake2",
"chat-proto",
"chat-sqlite 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"crypto 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"double-ratchets 0.0.1 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"hex", "hex",
"openmls", "openmls",
"openmls_traits 0.5.0", "openmls_traits 0.5.0",
"prost", "prost",
"rand_core 0.6.4", "rand_core 0.6.4",
"safer-ffi", "safer-ffi",
"storage", "storage 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"tempfile",
"thiserror", "thiserror",
"x25519-dalek", "x25519-dalek",
] ]
@ -1720,6 +1844,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "logos-account"
version = "0.1.0"
dependencies = [
"core_client",
"crypto 0.1.0",
"libchat 0.1.0",
]
[[package]] [[package]]
name = "lru" name = "lru"
version = "0.12.5" version = "0.12.5"
@ -2895,7 +3028,16 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "storage" name = "storage"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"crypto", "crypto 0.1.0",
"thiserror",
]
[[package]]
name = "storage"
version = "0.1.0"
source = "git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa#39bf26756448dd16ddff89a6c0054f79236494aa"
dependencies = [
"crypto 0.1.0 (git+https://github.com/logos-messaging/libchat?rev=39bf26756448dd16ddff89a6c0054f79236494aa)",
"thiserror", "thiserror",
] ]

View File

@ -3,21 +3,45 @@
resolver = "3" resolver = "3"
members = [ members = [
"core/sqlite", "bin/chat-cli",
"core/account",
"core/conversations", "core/conversations",
"core/core_client",
"core/crypto", "core/crypto",
"core/double-ratchets", "core/double-ratchets",
"core/integration_tests_core",
"core/sqlite",
"core/storage", "core/storage",
"crates/client",
"crates/client-ffi", "crates/client-ffi",
"bin/chat-cli", "crates/client",
"extensions/components",
]
default-members = [
"core/account",
"core/conversations",
"core/core_client",
"core/crypto",
"core/double-ratchets",
"core/integration_tests_core",
"core/sqlite",
"core/storage",
"crates/client-ffi",
"crates/client",
] ]
[workspace.dependencies] [workspace.dependencies]
blake2 = "0.10" # Internal Workspace dependency declarations (sorted)
chat-sqlite = { path = "core/sqlite" }
components = { path = "extensions/components" }
crypto = { path = "core/crypto" }
libchat = { path = "core/conversations" } libchat = { path = "core/conversations" }
logos-account = { path = "core/account" }
storage = { path = "core/storage" } storage = { path = "core/storage" }
# External Workspace dependency declarations (sorted)
blake2 = "0.10"
# Panicking across FFI boundaries is UB; abort is the correct strategy for a # Panicking across FFI boundaries is UB; abort is the correct strategy for a
# C FFI library. # C FFI library.
[profile.release] [profile.release]

View File

@ -8,16 +8,17 @@ name = "chat-cli"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
client = { path = "../../crates/client" } # External dependencies (sorted)
ratatui = "0.29"
crossterm = "0.29"
clap = { version = "4", features = ["derive"] }
anyhow = "1.0" anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
arboard = "3" arboard = "3"
base64 = "0.22" base64 = "0.22"
clap = { version = "4", features = ["derive"] }
# Reference a specific commit so updates to the Core does not break examples
client = { git = "https://github.com/logos-messaging/libchat", rev = "39bf26756448dd16ddff89a6c0054f79236494aa" }
crossterm = "0.29"
ratatui = "0.29"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

16
core/account/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "logos-account"
version = "0.1.0"
edition = "2024"
[features]
dev = []
[dependencies]
# Workspace dependencies (sorted)
crypto = { workspace = true }
libchat = { workspace = true }
core_client = {path = "../core_client"}
# External dependencies (sorted)

View File

@ -0,0 +1,45 @@
use std::fmt::Debug;
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
use libchat::{AccountId, IdentityProvider};
/// Logos Account represents a single account across
/// multiple installations and services.
#[derive(Debug)]
pub struct TestLogosAccount {
id: AccountId,
signing_key: Ed25519SigningKey,
verifying_key: Ed25519VerifyingKey,
}
/// A Test Focused LogosAccount using a pre-defined identifier.
/// The test account is not persisted, and uses a single user provided id
impl TestLogosAccount {
pub fn new(explicit_id: impl Into<String>) -> Self {
let signing_key = Ed25519SigningKey::generate();
let verifying_key = signing_key.verifying_key();
Self {
id: AccountId::new(explicit_id),
signing_key,
verifying_key,
}
}
}
impl IdentityProvider for TestLogosAccount {
fn account_id(&self) -> &AccountId {
&self.id
}
fn friendly_name(&self) -> String {
self.id.to_string()
}
fn public_key(&self) -> &Ed25519VerifyingKey {
&self.verifying_key
}
fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature {
self.signing_key.sign(payload)
}
}

5
core/account/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
#[cfg(feature = "dev")]
mod account;
#[cfg(feature = "dev")]
pub use account::TestLogosAccount;

View File

@ -7,21 +7,30 @@ edition = "2024"
crate-type = ["rlib","staticlib"] crate-type = ["rlib","staticlib"]
[dependencies] [dependencies]
# Workspace dependencies (sorted)
blake2 = { workspace = true }
chat-sqlite = { workspace = true }
crypto = { workspace = true }
storage = { workspace = true }
# External dependencies (sorted)
base64 = "0.22" base64 = "0.22"
sqlite = { package = "chat-sqlite", path = "../sqlite" }
blake2.workspace = true
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" } chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
crypto = { path = "../crypto" }
double-ratchets = { path = "../double-ratchets" } double-ratchets = { path = "../double-ratchets" }
hex = "0.4.3" hex = "0.4.3"
openmls = { version = "0.8.1", features = ["libcrux-provider"] }
openmls_libcrux_crypto = "0.3.1"
openmls_memory_storage = "0.5.0"
openmls_traits = "0.5.0"
prost = "0.14.1" prost = "0.14.1"
rand_core = { version = "0.6" } rand_core = { version = "0.6" }
safer-ffi = "0.1.13" safer-ffi = "0.1.13"
thiserror = "2.0.17" thiserror = "2.0.17"
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] } x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }
storage = { path = "../storage" }
openmls = { version = "0.8.1", features = ["libcrux-provider"] }
openmls_traits = "0.5.0"
[dev-dependencies] [dev-dependencies]
# Workspace dependencies (sorted)
components = { workspace = true }
# External dependencies (sorted)
tempfile = "3" tempfile = "3"

View File

@ -1,40 +0,0 @@
use crypto::Ed25519SigningKey;
use openmls::prelude::SignatureScheme;
use openmls_traits::signatures::Signer;
use crate::types::AccountId;
/// Logos Account represents a single account across
/// multiple installations and services.
pub struct LogosAccount {
id: AccountId,
signing_key: Ed25519SigningKey,
}
impl LogosAccount {
/// Create a test LogosAccount using a pre-defined identifier.
/// This should only be used during MLS integration. Not suitable for production use.
/// TODO: (P1) Remove once implementation is ready.
pub fn new_test(explicit_id: impl Into<String>) -> Self {
let signing_key = Ed25519SigningKey::generate();
Self {
id: AccountId::new(explicit_id.into()),
signing_key,
}
}
pub fn account_id(&self) -> &AccountId {
&self.id
}
}
impl Signer for LogosAccount {
// TODO: (P2) Remove OpenMLS dependency to make accounts more portable
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, openmls_traits::signatures::SignerError> {
Ok(self.signing_key.sign(payload).as_ref().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}

View File

@ -1,38 +1,62 @@
use std::cell::{Ref, RefMut};
use std::sync::Arc; use std::sync::Arc;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
use crate::conversation::{Convo, GroupConvo};
use crate::{DeliveryService, IdentityProvider, RegistrationService};
use crate::{
conversation::{Conversation, Id, PrivateV1Convo},
errors::ChatError,
inbox::Inbox,
inbox_v2::InboxV2,
proto::{EncryptedPayload, EnvelopeV1, Message},
types::{AccountId, AddressedEnvelope, ContentData},
};
use crypto::{Identity, PublicKey}; use crypto::{Identity, PublicKey};
use storage::{ChatStore, ConversationKind}; use storage::{ChatStore, ConversationKind};
use crate::account::LogosAccount; pub use crate::conversation::{ConversationId, ConversationIdOwned};
use crate::{
conversation::{Conversation, ConversationId, Convo, Id, PrivateV1Convo},
errors::ChatError,
inbox::Inbox,
proto::{EncryptedPayload, EnvelopeV1, Message},
types::{AddressedEnvelope, ContentData},
};
pub use crate::conversation::ConversationIdOwned;
pub use crate::inbox::Introduction; pub use crate::inbox::Introduction;
// This is the main entry point to the conversations api. // This is the main entry point to the conversations api.
// Ctx manages lifetimes of objects to process and generate payloads. // Ctx manages lifetimes of objects to process and generate payloads.
pub struct Context<S: ChatStore> { pub struct Context<
_identity: Rc<Identity>, IP: IdentityProvider,
inbox: Inbox<S>, DS: DeliveryService,
store: Rc<RefCell<S>>, RS: RegistrationService,
#[allow(unused)] // TODO: (P2) Remove once Account integrated in future PR. CS: ChatStore,
account: LogosAccount, > {
identity: Rc<Identity>,
ds: Rc<RefCell<DS>>,
store: Rc<RefCell<CS>>,
inbox: Inbox<CS>,
pq_inbox: InboxV2<IP, DS, RS, CS>,
} }
impl<S: ChatStore> Context<S> { impl<IP, DS, RS, CS> Context<IP, DS, RS, CS>
where
IP: IdentityProvider + 'static,
DS: DeliveryService + 'static,
RS: RegistrationService + 'static,
CS: ChatStore + 'static,
{
/// Opens or creates a Context with the given storage configuration. /// Opens or creates a Context with the given storage configuration.
/// ///
/// If an identity exists in storage, it will be restored. /// If an identity exists in storage, it will be restored.
/// Otherwise, a new identity will be created with the given name and saved. /// Otherwise, a new identity will be created with the given name and saved.
pub fn new_from_store(name: impl Into<String>, store: S) -> Result<Self, ChatError> { pub fn new_from_store(
name: impl Into<String>,
account: IP,
delivery: DS,
registration: RS,
store: CS,
) -> Result<Self, ChatError> {
let name = name.into(); let name = name.into();
// Services for sharing with Converastions/Inboxes
let ds = Rc::new(RefCell::new(delivery));
let contact_registry = Rc::new(RefCell::new(registration));
let store = Rc::new(RefCell::new(store)); let store = Rc::new(RefCell::new(store));
// Load or create identity // Load or create identity
@ -47,43 +71,89 @@ impl<S: ChatStore> Context<S> {
let identity = Rc::new(identity); let identity = Rc::new(identity);
let inbox = Inbox::new(Rc::clone(&store), Rc::clone(&identity)); let inbox = Inbox::new(Rc::clone(&store), Rc::clone(&identity));
let pq_inbox = InboxV2::new(account, ds.clone(), contact_registry.clone(), store.clone());
// Subscribe
ds.borrow_mut()
.subscribe(&pq_inbox.delivery_address())
.map_err(ChatError::generic)?;
Ok(Self { Ok(Self {
_identity: identity, identity,
inbox, ds,
store, store,
account: LogosAccount::new_test(name.as_str()), inbox,
pq_inbox,
}) })
} }
/// Creates a new in-memory Context (for testing). /// Creates a new in-memory Context (for testing).
/// ///
/// Uses in-memory SQLite database. Each call creates a new isolated database. /// Uses in-memory SQLite database. Each call creates a new isolated database.
pub fn new_with_name(name: impl Into<String>, chat_store: S) -> Self { pub fn new_with_name(
name: impl Into<String>,
account: IP,
delivery: DS,
registration: RS,
chat_store: CS,
) -> Result<Self, ChatError> {
let name = name.into(); let name = name.into();
let identity = Identity::new(&name); let identity = Identity::new(&name);
let chat_store = Rc::new(RefCell::new(chat_store));
chat_store // Services for sharing with Converastions/Inboxes
let ds = Rc::new(RefCell::new(delivery));
let contact_registry = Rc::new(RefCell::new(registration));
let store = Rc::new(RefCell::new(chat_store));
store
.borrow_mut() .borrow_mut()
.save_identity(&identity) .save_identity(&identity)
.expect("in-memory storage should not fail"); .expect("in-memory storage should not fail");
let identity = Rc::new(identity); let identity = Rc::new(identity);
let inbox = Inbox::new(Rc::clone(&chat_store), Rc::clone(&identity)); let inbox = Inbox::new(store.clone(), Rc::clone(&identity));
let mut pq_inbox =
InboxV2::new(account, ds.clone(), contact_registry.clone(), store.clone());
Self { // TODO: (P2) Initialize Account in Context or upper client.
_identity: identity, pq_inbox.register()?;
ds.borrow_mut()
.subscribe(&pq_inbox.delivery_address())
.map_err(ChatError::generic)?;
Ok(Self {
identity,
ds,
store,
pq_inbox,
inbox, inbox,
store: chat_store, })
account: LogosAccount::new_test(name.as_str()), }
}
pub fn ds(&self) -> RefMut<'_, DS> {
self.ds.borrow_mut()
}
pub fn store(&self) -> Ref<'_, CS> {
self.store.borrow()
}
pub fn identity(&self) -> &Identity {
&self.identity
}
/// Returns the unique identifier associated with the account
pub fn account_id(&self) -> &AccountId {
self.pq_inbox.account_id()
} }
pub fn installation_name(&self) -> &str { pub fn installation_name(&self) -> &str {
self._identity.get_name() self.identity.get_name()
} }
pub fn installation_key(&self) -> PublicKey { pub fn installation_key(&self) -> PublicKey {
self._identity.public_key() self.identity.public_key()
} }
pub fn create_private_convo( pub fn create_private_convo(
@ -96,7 +166,7 @@ impl<S: ChatStore> Context<S> {
.invite_to_private_convo(remote_bundle, content, Rc::clone(&self.store)) .invite_to_private_convo(remote_bundle, content, Rc::clone(&self.store))
.unwrap_or_else(|_| todo!("Log/Surface Error")); .unwrap_or_else(|_| todo!("Log/Surface Error"));
let remote_id = Inbox::<S>::inbox_identifier_for_key(*remote_bundle.installation_key()); let remote_id = Inbox::<CS>::inbox_identifier_for_key(*remote_bundle.installation_key());
let payload_bytes = payloads let payload_bytes = payloads
.into_iter() .into_iter()
.map(|p| p.into_envelope(remote_id.clone())) .map(|p| p.into_envelope(remote_id.clone()))
@ -106,6 +176,25 @@ impl<S: ChatStore> Context<S> {
Ok((convo_id, payload_bytes)) Ok((convo_id, payload_bytes))
} }
pub fn create_group_convo(
&mut self,
participants: &[&AccountId],
) -> Result<Box<dyn GroupConvo<DS, RS>>, ChatError> {
// TODO: (P1) Ensure errors are handled propertly. This is a high chance for desynchronized state.
// MlsGroup persistence, conversation persistence, and invite delivery all happen seperately
let mut convo = self.pq_inbox.create_group_v1()?;
self.store
.borrow_mut()
.save_conversation(&storage::ConversationMeta {
local_convo_id: convo.id().to_string(),
remote_convo_id: "0".into(),
kind: ConversationKind::GroupV1,
})?;
convo.add_member(participants)?;
Ok(Box::new(convo))
}
pub fn list_conversations(&self) -> Result<Vec<ConversationIdOwned>, ChatError> { pub fn list_conversations(&self) -> Result<Vec<ConversationIdOwned>, ChatError> {
let records = self.store.borrow().load_conversations()?; let records = self.store.borrow().load_conversations()?;
Ok(records Ok(records
@ -119,19 +208,13 @@ impl<S: ChatStore> Context<S> {
convo_id: ConversationId, convo_id: ConversationId,
content: &[u8], content: &[u8],
) -> Result<Vec<AddressedEnvelope>, ChatError> { ) -> Result<Vec<AddressedEnvelope>, ChatError> {
let convo = self.load_convo(convo_id)?; let mut convo = self.load_convo(convo_id)?;
let payloads = convo.send_message(content)?;
match convo { let remote_id = convo.remote_id();
Conversation::Private(mut convo) => { Ok(payloads
let payloads = convo.send_message(content)?; .into_iter()
let remote_id = convo.remote_id(); .map(|p| p.into_envelope(remote_id.clone()))
.collect())
Ok(payloads
.into_iter()
.map(|p| p.into_envelope(remote_id.clone()))
.collect())
}
}
} }
// Decode bytes and send to protocol for processing. // Decode bytes and send to protocol for processing.
@ -140,20 +223,30 @@ impl<S: ChatStore> Context<S> {
// TODO: Impl Conversation hinting // TODO: Impl Conversation hinting
let convo_id = env.conversation_hint; let convo_id = env.conversation_hint;
let enc = EncryptedPayload::decode(env.payload)?;
match convo_id { match convo_id {
c if c == self.inbox.id() => self.dispatch_to_inbox(enc), c if c == self.inbox.id() => self.dispatch_to_inbox(&env.payload),
c if self.store.borrow().has_conversation(&c)? => self.dispatch_to_convo(&c, enc), c if c == self.pq_inbox.id() => self.dispatch_to_inbox2(&env.payload),
_ => Ok(None), c if self.store.borrow().has_conversation(&c)? => {
self.dispatch_to_convo(&c, &env.payload)
}
_ => Ok(Some(ContentData {
conversation_id: "".into(),
data: vec![],
is_new_convo: false,
})),
} }
} }
// Dispatch encrypted payload to Inbox, and register the created Conversation // Dispatch encrypted payload to Inbox, and register the created Conversation
fn dispatch_to_inbox( fn dispatch_to_inbox(
&mut self, &mut self,
enc_payload: EncryptedPayload, enc_payload_bytes: &[u8],
) -> Result<Option<ContentData>, ChatError> { ) -> Result<Option<ContentData>, ChatError> {
let public_key_hex = Inbox::<S>::extract_ephemeral_key_hex(&enc_payload)?; // EncryptedPayloads are not used by GroupConvos at this time, else this can be performed in `handle_payload`
// TODO: (P1) reconcile envelope parsing between Covno and GroupConvo
let enc_payload = EncryptedPayload::decode(enc_payload_bytes)?;
let public_key_hex = Inbox::<CS>::extract_ephemeral_key_hex(&enc_payload)?;
let (convo, content) = let (convo, content) =
self.inbox self.inbox
.handle_frame(enc_payload, &public_key_hex, Rc::clone(&self.store))?; .handle_frame(enc_payload, &public_key_hex, Rc::clone(&self.store))?;
@ -168,20 +261,22 @@ impl<S: ChatStore> Context<S> {
Ok(content) Ok(content)
} }
// Dispatch encrypted payload to Inbox, and register the created Conversation
fn dispatch_to_inbox2(&mut self, payload: &[u8]) -> Result<Option<ContentData>, ChatError> {
self.pq_inbox.handle_frame(payload)?;
Ok(None)
}
// Dispatch encrypted payload to its corresponding conversation // Dispatch encrypted payload to its corresponding conversation
fn dispatch_to_convo( fn dispatch_to_convo(
&mut self, &mut self,
convo_id: ConversationId, convo_id: ConversationId,
enc_payload: EncryptedPayload, enc_payload_bytes: &[u8],
) -> Result<Option<ContentData>, ChatError> { ) -> Result<Option<ContentData>, ChatError> {
let convo = self.load_convo(convo_id)?; let enc_payload = EncryptedPayload::decode(enc_payload_bytes)?;
let mut convo = self.load_convo(convo_id)?;
match convo { convo.handle_frame(enc_payload)
Conversation::Private(mut convo) => {
let result = convo.handle_frame(enc_payload)?;
Ok(result)
}
}
} }
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> { pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
@ -189,8 +284,15 @@ impl<S: ChatStore> Context<S> {
Ok(intro.into()) Ok(intro.into())
} }
pub fn get_convo(
&mut self,
convo_id: ConversationId,
) -> Result<Box<dyn GroupConvo<DS, RS>>, ChatError> {
self.load_group_convo(convo_id)
}
/// Loads a conversation from DB by constructing it from metadata. /// Loads a conversation from DB by constructing it from metadata.
fn load_convo(&self, convo_id: ConversationId) -> Result<Conversation<S>, ChatError> { fn load_convo(&mut self, convo_id: ConversationId) -> Result<Box<dyn Convo>, ChatError> {
let record = self let record = self
.store .store
.borrow() .borrow()
@ -204,8 +306,35 @@ impl<S: ChatStore> Context<S> {
record.local_convo_id, record.local_convo_id,
record.remote_convo_id, record.remote_convo_id,
)?; )?;
Ok(Conversation::Private(private_convo)) Ok(Box::new(private_convo))
} }
ConversationKind::GroupV1 => Ok(Box::new(
self.pq_inbox.load_mls_convo(record.local_convo_id)?,
)),
ConversationKind::Unknown(_) => Err(ChatError::BadBundleValue(format!(
"unsupported conversation type: {}",
record.kind.as_str()
))),
}
}
fn load_group_convo(
&mut self,
convo_id: ConversationId,
) -> Result<Box<dyn GroupConvo<DS, RS>>, ChatError> {
let record = self
.store
.borrow()
.load_conversation(convo_id)?
.ok_or_else(|| ChatError::NoConvo(convo_id.into()))?;
match record.kind {
ConversationKind::PrivateV1 => {
Err(ChatError::NoConvo("This is not a group convo".into()))
}
ConversationKind::GroupV1 => Ok(Box::new(
self.pq_inbox.load_mls_convo(record.local_convo_id)?,
)),
ConversationKind::Unknown(_) => Err(ChatError::BadBundleValue(format!( ConversationKind::Unknown(_) => Err(ChatError::BadBundleValue(format!(
"unsupported conversation type: {}", "unsupported conversation type: {}",
record.kind.as_str() record.kind.as_str()
@ -213,155 +342,3 @@ impl<S: ChatStore> Context<S> {
} }
} }
} }
#[cfg(test)]
mod tests {
use sqlite::{ChatStorage, StorageConfig};
use storage::{ConversationStore, IdentityStore};
use tempfile::tempdir;
use super::*;
fn send_and_verify(
sender: &mut Context<ChatStorage>,
receiver: &mut Context<ChatStorage>,
convo_id: ConversationId,
content: &[u8],
) {
let payloads = sender.send_content(convo_id, content).unwrap();
let payload = payloads.first().unwrap();
let received = receiver
.handle_payload(&payload.data)
.unwrap()
.expect("expected content");
assert_eq!(content, received.data.as_slice());
assert!(!received.is_new_convo); // Check that `is_new_convo` is FALSE
}
#[test]
fn ctx_integration() {
let mut saro = Context::new_with_name("saro", ChatStorage::in_memory());
let mut raya = Context::new_with_name("raya", ChatStorage::in_memory());
// Raya creates intro bundle and sends to Saro
let bundle = raya.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).unwrap();
// Saro initiates conversation with Raya
let mut content = vec![10];
let (saro_convo_id, payloads) = saro.create_private_convo(&intro, &content).unwrap();
// Raya receives initial message
let payload = payloads.first().unwrap();
let initial_content = raya
.handle_payload(&payload.data)
.unwrap()
.expect("expected initial content");
let raya_convo_id = initial_content.conversation_id;
assert_eq!(content, initial_content.data);
assert!(initial_content.is_new_convo);
// Exchange messages back and forth
for _ in 0..10 {
content.push(content.last().unwrap() + 1);
send_and_verify(&mut raya, &mut saro, &raya_convo_id, &content);
content.push(content.last().unwrap() + 1);
send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content);
}
}
#[test]
fn identity_persistence() {
let store1 = ChatStorage::new(StorageConfig::InMemory).unwrap();
let ctx1 = Context::new_with_name("alice", store1);
let pubkey1 = ctx1._identity.public_key();
let name1 = ctx1.installation_name().to_string();
// For persistence tests with file-based storage, we'd need a shared db.
// With in-memory, we just verify the identity was created.
assert_eq!(name1, "alice");
assert!(!pubkey1.as_bytes().iter().all(|&b| b == 0));
}
#[test]
fn open_persists_new_identity() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("chat.sqlite");
let db_path = db_path.to_string_lossy().into_owned();
let store = ChatStorage::new(StorageConfig::File(db_path.clone())).unwrap();
let ctx = Context::new_from_store("alice", store).unwrap();
let pubkey = ctx._identity.public_key();
drop(ctx);
let store = ChatStorage::new(StorageConfig::File(db_path)).unwrap();
let persisted = store.load_identity().unwrap().unwrap();
assert_eq!(persisted.get_name(), "alice");
assert_eq!(persisted.public_key(), pubkey);
}
#[test]
fn conversation_metadata_persistence() {
let mut alice = Context::new_with_name("alice", ChatStorage::in_memory());
let mut bob = Context::new_with_name("bob", ChatStorage::in_memory());
let bundle = alice.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).unwrap();
let (_, payloads) = bob.create_private_convo(&intro, b"hi").unwrap();
let payload = payloads.first().unwrap();
let content = alice.handle_payload(&payload.data).unwrap().unwrap();
assert!(content.is_new_convo);
let convos = alice.store.borrow().load_conversations().unwrap();
assert_eq!(convos.len(), 1);
assert_eq!(convos[0].kind.as_str(), "private_v1");
}
#[test]
fn conversation_full_flow() {
let mut alice = Context::new_with_name("alice", ChatStorage::in_memory());
let mut bob = Context::new_with_name("bob", ChatStorage::in_memory());
let bundle = alice.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).unwrap();
let (bob_convo_id, payloads) = bob.create_private_convo(&intro, b"hello").unwrap();
let payload = payloads.first().unwrap();
let content = alice.handle_payload(&payload.data).unwrap().unwrap();
let alice_convo_id = content.conversation_id;
let payloads = alice.send_content(&alice_convo_id, b"reply 1").unwrap();
let payload = payloads.first().unwrap();
bob.handle_payload(&payload.data).unwrap().unwrap();
let payloads = bob.send_content(&bob_convo_id, b"reply 2").unwrap();
let payload = payloads.first().unwrap();
alice.handle_payload(&payload.data).unwrap().unwrap();
// Verify conversation list
let convo_ids = alice.list_conversations().unwrap();
assert_eq!(convo_ids.len(), 1);
// Continue exchanging messages
let payloads = bob.send_content(&bob_convo_id, b"more messages").unwrap();
let payload = payloads.first().unwrap();
let content = alice
.handle_payload(&payload.data)
.expect("should decrypt")
.expect("should have content");
assert_eq!(content.data, b"more messages");
// Alice can also send back
let payloads = alice.send_content(&alice_convo_id, b"alice reply").unwrap();
let payload = payloads.first().unwrap();
let content = bob
.handle_payload(&payload.data)
.unwrap()
.expect("bob should receive");
assert_eq!(content.data, b"alice reply");
}
}

View File

@ -1,12 +1,18 @@
mod group_v1;
mod privatev1; mod privatev1;
use crate::types::{AddressedEncryptedPayload, ContentData}; use crate::{
DeliveryService,
service_traits::KeyPackageProvider,
types::{AccountId, AddressedEncryptedPayload, ContentData},
};
use chat_proto::logoschat::encryption::EncryptedPayload; use chat_proto::logoschat::encryption::EncryptedPayload;
use std::fmt::Debug; use std::fmt::Debug;
use std::sync::Arc; use std::sync::Arc;
use storage::{ConversationKind, ConversationStore, RatchetStore}; use storage::{ConversationKind, ConversationStore, RatchetStore};
pub use crate::errors::ChatError; pub use crate::errors::ChatError;
pub use group_v1::GroupV1Convo;
pub use privatev1::PrivateV1Convo; pub use privatev1::PrivateV1Convo;
pub type ConversationId<'a> = &'a str; pub type ConversationId<'a> = &'a str;
@ -36,6 +42,14 @@ pub trait Convo: Id + Debug {
fn convo_type(&self) -> ConversationKind; fn convo_type(&self) -> ConversationKind;
} }
pub trait GroupConvo<DS: DeliveryService, RS: KeyPackageProvider>: Convo {
fn add_member(&mut self, members: &[&AccountId]) -> Result<(), ChatError>;
// This is intended to replace `send_message`. The trait change is that it automatically
// sends the payload directly.
fn send_content(&mut self, content: &[u8]) -> Result<(), ChatError>;
}
pub enum Conversation<S: ConversationStore + RatchetStore> { pub enum Conversation<S: ConversationStore + RatchetStore> {
Private(PrivateV1Convo<S>), Private(PrivateV1Convo<S>),
} }

View File

@ -0,0 +1,383 @@
/// GroupV1 is a conversationType which provides effecient handling of multiple participants
/// Properties:
/// - Harvest Now Decrypt Later (HNDL) protection provided by XWING
/// - Multiple
use std::cell::RefCell;
use std::rc::Rc;
use blake2::{Blake2b, Digest, digest::consts::U6};
use chat_proto::logoschat::encryption::{EncryptedPayload, Plaintext, encrypted_payload};
use openmls::prelude::tls_codec::Deserialize;
use openmls::prelude::*;
use storage::ConversationKind;
use crate::inbox_v2::{MlsIdentityProvider, MlsProvider};
use crate::types::AccountId;
use crate::{
DeliveryService,
conversation::{ChatError, ConversationId, Convo, GroupConvo, Id},
service_traits::{IdentityProvider, KeyPackageProvider},
types::{AddressedEncryptedPayload, ContentData},
};
pub struct GroupV1Convo<IP: IdentityProvider, MP, DS, KP> {
identity_provider: Rc<RefCell<MlsIdentityProvider<IP>>>,
mls_provider: Rc<RefCell<MP>>,
ds: Rc<RefCell<DS>>,
keypkg_provider: Rc<RefCell<KP>>,
mls_group: MlsGroup,
convo_id: String,
}
impl<IP, MP, DS, KP> std::fmt::Debug for GroupV1Convo<IP, MP, DS, KP>
where
IP: IdentityProvider,
MP: MlsProvider,
DS: DeliveryService,
KP: KeyPackageProvider,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GroupV1Convo")
.field("name", &self.identity_provider.borrow().friendly_name())
.field("convo_id", &self.convo_id)
.field("mls_epoch", &self.mls_group.epoch())
.finish_non_exhaustive()
}
}
impl<IP, MP, DS, KP> GroupV1Convo<IP, MP, DS, KP>
where
IP: IdentityProvider,
MP: MlsProvider,
DS: DeliveryService,
KP: KeyPackageProvider,
{
// Create a new conversation with the creator as the only participant.
pub fn new(
identity_provider: Rc<RefCell<MlsIdentityProvider<IP>>>,
mls_provider: Rc<RefCell<MP>>,
ds: Rc<RefCell<DS>>,
keypkg_provider: Rc<RefCell<KP>>,
) -> Result<Self, ChatError> {
let config = Self::mls_create_config();
let mls_group = {
let mls_provider_ref = mls_provider.borrow();
let signer = identity_provider.borrow();
let credential = signer.get_credential();
MlsGroup::new(&*mls_provider_ref, &*signer, &config, credential).unwrap()
};
let convo_id = hex::encode(mls_group.group_id().as_slice());
Self::subscribe(&mut ds.borrow_mut(), &convo_id)?;
Ok(Self {
identity_provider,
mls_provider,
ds,
keypkg_provider,
mls_group,
convo_id,
})
}
// Constructs a new conversation upon receiving a MlsWelcome message.
pub fn new_from_welcome(
identity_provider: Rc<RefCell<MlsIdentityProvider<IP>>>,
mls_provider: Rc<RefCell<MP>>,
ds: Rc<RefCell<DS>>,
keypkg_provider: Rc<RefCell<KP>>,
welcome: Welcome,
) -> Result<Self, ChatError> {
let mls_group = {
let provider = &*mls_provider.borrow();
StagedWelcome::build_from_welcome(provider, &Self::mls_join_config(), welcome)
.unwrap()
.build()
.unwrap()
.into_group(provider)
.unwrap()
};
let convo_id = hex::encode(mls_group.group_id().as_slice());
Self::subscribe(&mut *ds.borrow_mut(), &convo_id)?;
Ok(Self {
identity_provider,
mls_provider,
ds,
keypkg_provider,
mls_group,
convo_id,
})
}
pub fn load(
identity_provider: Rc<RefCell<MlsIdentityProvider<IP>>>,
mls_provider: Rc<RefCell<MP>>,
ds: Rc<RefCell<DS>>,
keypkg_provider: Rc<RefCell<KP>>,
convo_id: String,
group_id: GroupId,
) -> Result<Self, ChatError> {
let mls_group = MlsGroup::load(mls_provider.borrow().storage(), &group_id)
.map_err(ChatError::generic)?
.ok_or_else(|| ChatError::NoConvo("mls group not found".into()))?;
Self::subscribe(&mut *ds.borrow_mut(), &convo_id)?;
Ok(GroupV1Convo {
identity_provider,
mls_provider,
ds,
keypkg_provider,
mls_group,
convo_id,
})
}
// Configure the delivery service to listen for the required delivery addresses.
fn subscribe(ds: &mut DS, convo_id: &str) -> Result<(), ChatError> {
ds.subscribe(&Self::delivery_address_from_id(convo_id))
.map_err(ChatError::generic)?;
ds.subscribe(&Self::ctrl_delivery_address_from_id(convo_id))
.map_err(ChatError::generic)?;
Ok(())
}
fn mls_create_config() -> MlsGroupCreateConfig {
MlsGroupCreateConfig::builder()
.ciphersuite(Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519)
.use_ratchet_tree_extension(true) // This is handy for now, until there is central store for this data
.build()
}
fn mls_join_config() -> MlsGroupJoinConfig {
MlsGroupJoinConfig::builder().build()
}
fn delivery_address_from_id(convo_id: &str) -> String {
let hash = Blake2b::<U6>::new()
.chain_update("delivery_addr|")
.chain_update(convo_id)
.finalize();
hex::encode(hash)
}
fn delivery_address(&self) -> String {
Self::delivery_address_from_id(&self.convo_id)
}
fn ctrl_delivery_address_from_id(convo_id: &str) -> String {
let hash = Blake2b::<U6>::new()
.chain_update("ctrl_delivery_addr|")
.chain_update(convo_id)
.finalize();
hex::encode(hash)
}
fn ctrl_delivery_address(&self) -> String {
Self::ctrl_delivery_address_from_id(&self.convo_id)
}
fn key_package_for_account(&self, ident: &AccountId) -> Result<KeyPackage, ChatError> {
let retrieved_bytes = self
.keypkg_provider
.borrow()
.retrieve(ident)
.map_err(|e: KP::Error| ChatError::Generic(e.to_string()))?;
// dbg!(ctx.contact_registry());
let Some(keypkg_bytes) = retrieved_bytes else {
return Err(ChatError::Protocol("Contact Not Found".into()));
};
let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?;
let keypkg =
key_package_in.validate(self.mls_provider.borrow().crypto(), ProtocolVersion::Mls10)?; //TODO: P3 - Hardcoded Protocol Version
Ok(keypkg)
}
}
impl<IP, MP, DS, KP> Id for GroupV1Convo<IP, MP, DS, KP>
where
IP: IdentityProvider,
MP: MlsProvider,
DS: DeliveryService,
KP: KeyPackageProvider,
{
fn id(&self) -> ConversationId<'_> {
&self.convo_id
}
}
impl<IP, MP, DS, KP> Convo for GroupV1Convo<IP, MP, DS, KP>
where
IP: IdentityProvider,
MP: MlsProvider,
DS: DeliveryService,
KP: KeyPackageProvider,
{
fn send_message(
&mut self,
content: &[u8],
) -> Result<Vec<AddressedEncryptedPayload>, ChatError> {
let mls_message_out = self
.mls_group
.create_message(
&*self.mls_provider.borrow(),
&*self.identity_provider.borrow(),
content,
)
.unwrap();
let a = AddressedEncryptedPayload {
delivery_address: self.delivery_address(),
data: EncryptedPayload {
encryption: Some(encrypted_payload::Encryption::Plaintext(Plaintext {
payload: mls_message_out.to_bytes().unwrap().into(),
})),
},
};
Ok(vec![a])
}
fn handle_frame(
&mut self,
encoded_payload: EncryptedPayload,
) -> Result<Option<ContentData>, ChatError> {
let bytes = match encoded_payload.encryption {
Some(encrypted_payload::Encryption::Plaintext(pt)) => pt.payload,
_ => {
return Err(ChatError::ProtocolExpectation(
"None",
"Some(Encryption::Plaintext)".into(),
));
}
};
let mls_message =
MlsMessageIn::tls_deserialize_exact_bytes(&bytes).map_err(ChatError::generic)?;
let protocol_message: ProtocolMessage = mls_message
.try_into_protocol_message()
.map_err(ChatError::generic)?;
let provider = &*self.mls_provider.borrow();
if protocol_message.epoch() < self.mls_group.epoch() {
// TODO: (P1) Add logging for messages arriving from past epoch.
return Ok(None);
}
let processed = self
.mls_group
.process_message(provider, protocol_message)
.map_err(ChatError::generic)?;
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(msg) => Ok(Some(ContentData {
conversation_id: hex::encode(self.mls_group.group_id().as_slice()),
data: msg.into_bytes(),
is_new_convo: false,
})),
ProcessedMessageContent::StagedCommitMessage(commit) => {
self.mls_group
.merge_staged_commit(provider, *commit)
.map_err(ChatError::generic)?;
Ok(None)
}
_ => {
// TODO: (P2) Log unknown message type
Ok(None)
}
}
}
fn remote_id(&self) -> String {
// "group_remote_id".into()
todo!()
}
fn convo_type(&self) -> storage::ConversationKind {
ConversationKind::GroupV1
}
}
impl<IP, MP, DS, KP> GroupConvo<DS, KP> for GroupV1Convo<IP, MP, DS, KP>
where
IP: IdentityProvider,
MP: MlsProvider,
DS: DeliveryService,
KP: KeyPackageProvider,
{
// add_members returns:
// commit — the Commit message Alice broadcasts to all members
// welcome — the Welcome message sent privately to each new joiner
// _group_info — used for external joins; ignore for now
fn add_member(&mut self, members: &[&AccountId]) -> Result<(), ChatError> {
let identity_provider = &*self.identity_provider.borrow();
let mls_provider = &*self.mls_provider.borrow();
if members.len() > 50 {
// This is a temporary limit that originates from the the De-MLS epoch time.
return Err(ChatError::Protocol(
"Cannot add more than 50 Members at a time".into(),
));
}
// Get the Keypacakages and transpose any errors.
// The account_id is kept so invites can be addressed properly
let keypkgs = members
.iter()
.map(|ident| self.key_package_for_account(ident))
.collect::<Result<Vec<_>, ChatError>>()?;
let (commit, welcome, _group_info) = self
.mls_group
.add_members(mls_provider, identity_provider, keypkgs.iter().as_slice())
.unwrap();
self.mls_group.merge_pending_commit(mls_provider).unwrap();
// TODO: (P3) Evaluate privacy/performance implications of an aggregated Welcome for multiple users
for account_id in members {
self.mls_provider.borrow().invite_user(
&mut *self.ds.borrow_mut(),
account_id,
&welcome,
)?;
}
let encrypted_payload = EncryptedPayload {
encryption: Some(encrypted_payload::Encryption::Plaintext(Plaintext {
payload: commit.to_bytes()?.into(),
})),
};
let addr_enc_payload = AddressedEncryptedPayload {
delivery_address: self.ctrl_delivery_address(),
data: encrypted_payload,
};
// Prepare commit message
// TODO: (P1) Make GroupConvos agnostic to framing so its less error prone and more
let env = addr_enc_payload.into_envelope(self.convo_id.clone());
self.ds
.borrow_mut()
.publish(env)
.map_err(|e| ChatError::Generic(format!("Publish: {e}")))
}
fn send_content(&mut self, content: &[u8]) -> Result<(), ChatError> {
let payloads = self.send_message(content)?;
for payload in payloads {
self.ds
.borrow_mut()
.publish(payload.into_envelope(self.id().into()))
.map_err(|e| ChatError::Delivery(e.to_string()))?;
}
Ok(())
}
}

View File

@ -281,8 +281,8 @@ impl<S: ConversationStore + RatchetStore> Debug for PrivateV1Convo<S> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chat_sqlite::{ChatStorage, StorageConfig};
use crypto::PrivateKey; use crypto::PrivateKey;
use sqlite::{ChatStorage, StorageConfig};
use super::*; use super::*;

View File

@ -1,3 +1,4 @@
use openmls::{framing::errors::MlsMessageError, prelude::tls_codec};
pub use thiserror::Error; pub use thiserror::Error;
use storage::StorageError; use storage::StorageError;
@ -26,6 +27,23 @@ pub enum ChatError {
UnsupportedConvoType(String), UnsupportedConvoType(String),
#[error("storage error: {0}")] #[error("storage error: {0}")]
Storage(#[from] StorageError), Storage(#[from] StorageError),
#[error("mls error: {0}")]
MlsMessageError(#[from] MlsMessageError),
#[error("TlsCodec: {0}")]
TlsCodec(#[from] tls_codec::Error),
#[error("generic: {0}")]
Generic(String),
#[error("KeyPackage: {0}")]
KeyPackage(#[from] openmls::prelude::KeyPackageVerifyError),
#[error("Delivery: {0}")]
Delivery(String),
}
impl ChatError {
// This is a stopgap until there is a proper error system in place
pub fn generic(e: impl ToString) -> Self {
Self::Generic(e.to_string())
}
} }
#[derive(Error, Debug)] #[derive(Error, Debug)]

View File

@ -260,7 +260,7 @@ mod tests {
use std::cell::RefCell; use std::cell::RefCell;
use super::*; use super::*;
use sqlite::{ChatStorage, StorageConfig}; use chat_sqlite::{ChatStorage, StorageConfig};
#[test] #[test]
fn test_invite_privatev1_roundtrip() { fn test_invite_privatev1_roundtrip() {

View File

@ -97,7 +97,7 @@ mod tests {
let bob_bundle = PrekeyBundle { let bob_bundle = PrekeyBundle {
identity_key: PublicKey::from(&bob_identity), identity_key: PublicKey::from(&bob_identity),
signed_prekey: bob_signed_prekey_pub, signed_prekey: bob_signed_prekey_pub,
signature: crypto::Ed25519Signature([0u8; 64]), signature: crypto::XedDsaSignature([0u8; 64]),
onetime_prekey: None, onetime_prekey: None,
}; };

View File

@ -1,6 +1,6 @@
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chat_proto::logoschat::intro::IntroBundle; use chat_proto::logoschat::intro::IntroBundle;
use crypto::{Ed25519Signature, PrivateKey, PublicKey}; use crypto::{PrivateKey, PublicKey, XedDsaSignature};
use prost::Message; use prost::Message;
use rand_core::{CryptoRng, RngCore}; use rand_core::{CryptoRng, RngCore};
@ -19,7 +19,7 @@ pub(crate) fn sign_intro_binding<R: RngCore + CryptoRng>(
secret: &PrivateKey, secret: &PrivateKey,
ephemeral: &PublicKey, ephemeral: &PublicKey,
rng: R, rng: R,
) -> Ed25519Signature { ) -> XedDsaSignature {
let message = intro_binding_message(ephemeral); let message = intro_binding_message(ephemeral);
crypto::xeddsa_sign(secret, &message, rng) crypto::xeddsa_sign(secret, &message, rng)
} }
@ -27,7 +27,7 @@ pub(crate) fn sign_intro_binding<R: RngCore + CryptoRng>(
pub(crate) fn verify_intro_binding( pub(crate) fn verify_intro_binding(
pubkey: &PublicKey, pubkey: &PublicKey,
ephemeral: &PublicKey, ephemeral: &PublicKey,
signature: &Ed25519Signature, signature: &XedDsaSignature,
) -> Result<(), crypto::SignatureError> { ) -> Result<(), crypto::SignatureError> {
let message = intro_binding_message(ephemeral); let message = intro_binding_message(ephemeral);
crypto::xeddsa_verify(pubkey, &message, signature) crypto::xeddsa_verify(pubkey, &message, signature)
@ -37,7 +37,7 @@ pub(crate) fn verify_intro_binding(
pub struct Introduction { pub struct Introduction {
installation_key: PublicKey, installation_key: PublicKey,
ephemeral_key: PublicKey, ephemeral_key: PublicKey,
signature: Ed25519Signature, signature: XedDsaSignature,
} }
impl Introduction { impl Introduction {
@ -64,7 +64,7 @@ impl Introduction {
&self.ephemeral_key &self.ephemeral_key
} }
pub fn signature(&self) -> &Ed25519Signature { pub fn signature(&self) -> &XedDsaSignature {
&self.signature &self.signature
} }
} }
@ -127,7 +127,7 @@ impl TryFrom<&[u8]> for Introduction {
let installation_key = PublicKey::from(installation_bytes); let installation_key = PublicKey::from(installation_bytes);
let ephemeral_key = PublicKey::from(ephemeral_bytes); let ephemeral_key = PublicKey::from(ephemeral_bytes);
let signature = Ed25519Signature(signature_bytes); let signature = XedDsaSignature::from(signature_bytes);
verify_intro_binding(&installation_key, &ephemeral_key, &signature) verify_intro_binding(&installation_key, &ephemeral_key, &signature)
.map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?; .map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?;

View File

@ -0,0 +1,336 @@
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc;
use chat_proto::logoschat::envelope::EnvelopeV1;
use openmls::prelude::tls_codec::Serialize;
use openmls::prelude::*;
use openmls_libcrux_crypto::CryptoProvider as LibcruxCryptoProvider;
use openmls_memory_storage::MemoryStorage;
use openmls_traits::signatures::Signer;
use openmls_traits::signatures::SignerError;
use prost::{Message, Oneof};
use storage::ChatStore;
use storage::ConversationMeta;
use crate::AddressedEnvelope;
use crate::ChatError;
use crate::DeliveryService;
use crate::IdentityProvider;
use crate::RegistrationService;
use crate::conversation::{GroupConvo, GroupV1Convo};
use crate::types::AccountId;
use crate::utils::{blake2b_hex, hash_size};
// Define unique Identifiers derivations used in InboxV2
fn delivery_address_for(account_id: &AccountId) -> String {
blake2b_hex::<hash_size::AccountId>(&["InboxV2|", "delivery_address|", account_id.as_str()])
}
fn conversation_id_for(account_id: &AccountId) -> String {
blake2b_hex::<hash_size::ConvoId>(&["InboxV2|", "conversation_id|", account_id.as_str()])
}
#[derive(Debug)]
pub struct MlsIdentityProvider<T: IdentityProvider>(T);
impl<T: IdentityProvider> MlsIdentityProvider<T> {
pub fn get_credential(&self) -> CredentialWithKey {
CredentialWithKey {
credential: BasicCredential::new(self.0.friendly_name().into()).into(),
signature_key: self.0.public_key().as_ref().into(),
}
}
}
impl<T: IdentityProvider> Deref for MlsIdentityProvider<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T: IdentityProvider> IdentityProvider for MlsIdentityProvider<T> {
fn account_id(&self) -> &AccountId {
self.0.account_id()
}
fn friendly_name(&self) -> String {
self.0.friendly_name()
}
fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature {
self.0.sign(payload)
}
fn public_key(&self) -> &crypto::Ed25519VerifyingKey {
self.0.public_key()
}
}
impl<T: IdentityProvider> Signer for MlsIdentityProvider<T> {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, SignerError> {
Ok(self.0.sign(payload).as_ref().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}
/// An Extension trait which extends OpenMlsProvider to add required functionality
/// All MLS based Conversation should use this trait for defining requirements.
pub trait MlsProvider: OpenMlsProvider {
fn invite_user<DS: DeliveryService>(
&self,
ds: &mut DS,
account_id: &AccountId,
welcome: &MlsMessageOut,
) -> Result<(), ChatError>;
}
/// This is a PQ based provider that uses in memory storage.
pub struct MlsEphemeralPqProvider {
crypto: LibcruxCryptoProvider,
storage: MemoryStorage,
}
impl MlsEphemeralPqProvider {
pub fn new() -> Result<Self, CryptoError> {
let crypto = LibcruxCryptoProvider::new()?;
let storage = MemoryStorage::default();
Ok(Self { crypto, storage })
}
}
impl MlsProvider for MlsEphemeralPqProvider {
fn invite_user<DS: DeliveryService>(
&self,
ds: &mut DS,
account_id: &AccountId,
welcome: &MlsMessageOut,
) -> Result<(), ChatError> {
let invite = GroupV1HeavyInvite {
welcome_bytes: welcome.to_bytes()?,
};
let frame = InboxV2Frame {
payload: Some(InviteType::GroupV1(invite)),
};
let envelope = EnvelopeV1 {
conversation_hint: conversation_id_for(account_id),
salt: 0,
payload: frame.encode_to_vec().into(),
};
let outbound_msg = AddressedEnvelope {
delivery_address: delivery_address_for(account_id),
data: envelope.encode_to_vec(),
};
ds.publish(outbound_msg).map_err(ChatError::generic)?;
Ok(())
}
}
impl OpenMlsProvider for MlsEphemeralPqProvider {
type CryptoProvider = LibcruxCryptoProvider;
type RandProvider = LibcruxCryptoProvider;
type StorageProvider = openmls_memory_storage::MemoryStorage;
fn storage(&self) -> &Self::StorageProvider {
&self.storage
}
fn crypto(&self) -> &Self::CryptoProvider {
&self.crypto
}
fn rand(&self) -> &Self::RandProvider {
&self.crypto
}
}
/// An PQ focused Conversation initializer.
/// InboxV2 Incorporates an Account based identity system to support PQ based conversation protocols
/// such as MLS.
pub struct InboxV2<IP, DS, RS, CS>
where
IP: IdentityProvider,
{
account_id: AccountId,
account: Rc<RefCell<MlsIdentityProvider<IP>>>,
ds: Rc<RefCell<DS>>,
reg_service: Rc<RefCell<RS>>,
store: Rc<RefCell<CS>>,
mls_provider: Rc<RefCell<MlsEphemeralPqProvider>>,
}
impl<IP, DS, CS, RS> InboxV2<IP, DS, RS, CS>
where
IP: IdentityProvider,
DS: DeliveryService,
RS: RegistrationService,
CS: ChatStore,
{
pub fn new(
account: IP,
ds: Rc<RefCell<DS>>,
reg_service: Rc<RefCell<RS>>,
store: Rc<RefCell<CS>>,
) -> Self {
// Avoid referencing a temporary value by caching it.
let account_id = account.account_id().clone();
let provider = MlsEphemeralPqProvider::new().unwrap();
Self {
account_id,
account: Rc::new(RefCell::new(MlsIdentityProvider(account))),
ds,
reg_service,
store,
mls_provider: Rc::new(RefCell::new(provider)),
}
}
pub fn account_id(&self) -> &AccountId {
&self.account_id
}
/// Submit MlsKeypackage to registration service
pub fn register(&mut self) -> Result<(), ChatError> {
let keypackage_bytes = self.create_keypackage()?.tls_serialize_detached()?;
// TODO: (P3) Each keypackage can only be used once — enable LastResort or publish multiple
self.reg_service
.borrow_mut()
.register(&self.account.borrow().friendly_name(), keypackage_bytes)
.map_err(ChatError::generic)
}
pub fn delivery_address(&self) -> String {
delivery_address_for(&self.account_id)
}
pub fn id(&self) -> String {
conversation_id_for(&self.account_id)
}
pub fn create_group_v1(
&self,
) -> Result<GroupV1Convo<IP, MlsEphemeralPqProvider, DS, RS>, ChatError> {
GroupV1Convo::new(
self.account.clone(),
self.mls_provider.clone(),
self.ds.clone(),
self.reg_service.clone(),
)
}
pub fn handle_frame(&self, payload_bytes: &[u8]) -> Result<(), ChatError> {
let inbox_frame = InboxV2Frame::decode(payload_bytes)?;
let Some(payload) = inbox_frame.payload else {
return Err(ChatError::BadParsing("InboxV2Payload missing"));
};
match payload {
InviteType::GroupV1(group_v1_heavy_invite) => {
self.handle_heavy_invite(group_v1_heavy_invite)
}
}
}
fn persist_convo(&self, convo: impl GroupConvo<DS, RS>) -> Result<(), ChatError> {
// TODO: (P2) Remove remote_convo_id this is an implementation detail specific to PrivateV1
// TODO: (P3) Implement From<Convo> for ConversationMeta
let meta = ConversationMeta {
local_convo_id: convo.id().to_string(),
remote_convo_id: "0".into(),
kind: storage::ConversationKind::GroupV1,
};
self.store.borrow_mut().save_conversation(&meta)?;
// TODO: (P1) Persist state
Ok(())
}
fn handle_heavy_invite(&self, invite: GroupV1HeavyInvite) -> Result<(), ChatError> {
let (msg_in, _rest) = MlsMessageIn::tls_deserialize_bytes(invite.welcome_bytes.as_slice())?;
let MlsMessageBodyIn::Welcome(welcome) = msg_in.extract() else {
return Err(ChatError::ProtocolExpectation(
"something else",
"Welcome".into(),
));
};
let convo = GroupV1Convo::new_from_welcome(
self.account.clone(),
self.mls_provider.clone(),
self.ds.clone(),
self.reg_service.clone(),
welcome,
)?;
self.persist_convo(convo)
}
fn create_keypackage(&self) -> Result<KeyPackage, ChatError> {
let capabilities = Capabilities::builder()
.ciphersuites(vec![
Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519,
])
.extensions(vec![ExtensionType::ApplicationId])
.build();
let signer = self.account.borrow();
let a = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519,
&*self.mls_provider.borrow(),
&*signer,
signer.get_credential(),
)
.expect("Failed to build KeyPackage");
Ok(a.key_package().clone())
}
pub fn load_mls_convo(
&self,
convo_id: String,
) -> Result<GroupV1Convo<IP, MlsEphemeralPqProvider, DS, RS>, ChatError> {
let group_id_bytes = hex::decode(&convo_id).map_err(ChatError::generic)?;
let group_id = GroupId::from_slice(&group_id_bytes);
let convo = GroupV1Convo::load(
self.account.clone(),
self.mls_provider.clone(),
self.ds.clone(),
self.reg_service.clone(),
convo_id,
group_id,
)?;
Ok(convo)
}
}
#[derive(Clone, PartialEq, Message)]
pub struct InboxV2Frame {
#[prost(oneof = "InviteType", tags = "1")]
pub payload: Option<InviteType>,
}
#[derive(Clone, PartialEq, Oneof)]
pub enum InviteType {
#[prost(message, tag = "1")]
GroupV1(GroupV1HeavyInvite),
}
#[derive(Clone, PartialEq, Message)]
pub struct GroupV1HeavyInvite {
#[prost(bytes, tag = "1")]
pub welcome_bytes: Vec<u8>,
}

View File

@ -1,16 +1,19 @@
mod account;
mod context; mod context;
mod conversation; mod conversation;
mod crypto; mod crypto;
mod errors; mod errors;
mod inbox; mod inbox;
mod inbox_v2;
mod proto; mod proto;
mod service_traits;
mod types; mod types;
mod utils; mod utils;
pub use account::LogosAccount; pub use chat_sqlite::ChatStorage;
pub use context::{Context, ConversationIdOwned, Introduction}; pub use chat_sqlite::StorageConfig;
pub use context::{Context, ConversationId, ConversationIdOwned, Introduction};
pub use conversation::GroupConvo;
pub use errors::ChatError; pub use errors::ChatError;
pub use sqlite::ChatStorage; pub use service_traits::{DeliveryService, IdentityProvider, RegistrationService};
pub use sqlite::StorageConfig; pub use types::{AccountId, AddressedEncryptedPayload, AddressedEnvelope, ContentData};
pub use types::{AddressedEnvelope, ContentData}; pub use utils::hex_trunc;

View File

@ -0,0 +1,67 @@
/// Service traits define the functionality which must be externally supplied by
/// platform clients. Platforms can alter the behaviour of the chat core by supplying
/// different implementations.
use std::{fmt::Debug, fmt::Display};
use crypto::{Ed25519Signature, Ed25519VerifyingKey};
use crate::types::{AccountId, AddressedEnvelope};
/// A Delivery service is responsible for payload transport.
/// This interface allows Conversations to send payloads on the wire as well as
/// register interest in delivery_addresses. Client implementations are responsible
/// for providing the inbound payloads to Context::handle_payload.
pub trait DeliveryService: Debug {
type Error: Display;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>;
fn subscribe(&mut self, delivery_address: &str) -> Result<(), Self::Error>;
}
/// Manages key bundle storage for MLS group creation/addition while contacts are
/// offline.
///
/// Implement this to provide a contact registry — ach participant publishes their key package
/// on registration; others fetch it to initiate a conversation.
pub trait RegistrationService: Debug {
type Error: Display;
fn register(&mut self, identity: &str, key_bundle: Vec<u8>) -> Result<(), Self::Error>;
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error>;
}
/// Read-only view of a contact registry. Not part of the public API.
/// Satisfied automatically by any `RegistrationService` implementation.
pub trait KeyPackageProvider: Debug {
type Error: Display;
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error>;
}
impl<T: RegistrationService> KeyPackageProvider for T {
type Error = T::Error;
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error> {
RegistrationService::retrieve(self, identity)
}
}
/// Represents an external Identity
/// Implement this to provide an Authentication model for users/installations
pub trait IdentityProvider: Debug {
fn account_id(&self) -> &AccountId;
fn friendly_name(&self) -> String;
fn sign(&self, payload: &[u8]) -> Ed25519Signature;
fn public_key(&self) -> &Ed25519VerifyingKey;
}
impl<T: IdentityProvider> IdentityProvider for &T {
fn account_id(&self) -> &AccountId {
(**self).account_id()
}
fn friendly_name(&self) -> String {
(**self).friendly_name()
}
fn sign(&self, payload: &[u8]) -> Ed25519Signature {
(**self).sign(payload)
}
fn public_key(&self) -> &Ed25519VerifyingKey {
(**self).public_key()
}
}

View File

@ -1,4 +1,4 @@
use std::fmt; use std::fmt::{self, Debug};
use crate::proto::{self, Message}; use crate::proto::{self, Message};
@ -6,13 +6,51 @@ use crate::proto::{self, Message};
// This struct represents Outbound data. // This struct represents Outbound data.
// It wraps an encoded payload with a delivery address, so it can be handled by the delivery service. // It wraps an encoded payload with a delivery address, so it can be handled by the delivery service.
#[derive(Clone)]
pub struct AddressedEnvelope { pub struct AddressedEnvelope {
pub delivery_address: String, pub delivery_address: String,
pub data: Vec<u8>, pub data: Vec<u8>,
} }
impl AddressedEnvelope {
pub fn new(delivery_address: String, convo_id: String, data: &[u8]) -> Self {
let envelope = proto::EnvelopeV1 {
// TODO: conversation_id should be obscured
conversation_hint: convo_id,
salt: 0,
payload: proto::Bytes::copy_from_slice(data),
};
AddressedEnvelope {
delivery_address,
data: envelope.encode_to_vec(),
}
}
}
impl Debug for AddressedEnvelope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let data = &self.data;
let hex = if data.len() <= 8 {
hex::encode(data)
} else {
format!(
"{}..{}",
hex::encode(&data[..4]),
hex::encode(&data[data.len() - 4..])
)
};
f.debug_struct("AddressedEnvelope")
.field("addr", &self.delivery_address)
.field("data", &hex)
.finish()
}
}
// This struct represents the result of processed inbound data. // This struct represents the result of processed inbound data.
// It wraps content payload with a conversation_id // It wraps content payload with a conversation_id
#[derive(Debug)]
pub struct ContentData { pub struct ContentData {
pub conversation_id: String, pub conversation_id: String,
pub data: Vec<u8>, pub data: Vec<u8>,
@ -22,7 +60,7 @@ pub struct ContentData {
// Internal type Definitions // Internal type Definitions
// Used by Conversations to attach addresses to outbound encrypted payloads // Used by Conversations to attach addresses to outbound encrypted payloads
pub(crate) struct AddressedEncryptedPayload { pub struct AddressedEncryptedPayload {
pub delivery_address: String, pub delivery_address: String,
pub data: proto::EncryptedPayload, pub data: proto::EncryptedPayload,
} }
@ -30,17 +68,11 @@ pub(crate) struct AddressedEncryptedPayload {
impl AddressedEncryptedPayload { impl AddressedEncryptedPayload {
// Wrap in an envelope and prepare for transmission // Wrap in an envelope and prepare for transmission
pub fn into_envelope(self, convo_id: String) -> AddressedEnvelope { pub fn into_envelope(self, convo_id: String) -> AddressedEnvelope {
let envelope = proto::EnvelopeV1 { AddressedEnvelope::new(
// TODO: conversation_id should be obscured self.delivery_address,
conversation_hint: convo_id, convo_id,
salt: 0, self.data.encode_to_vec().as_slice(),
payload: proto::Bytes::copy_from_slice(self.data.encode_to_vec().as_slice()), )
};
AddressedEnvelope {
delivery_address: self.delivery_address,
data: envelope.encode_to_vec(),
}
} }
} }

View File

@ -1,3 +1,4 @@
use blake2::{Blake2b, Digest};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
pub fn timestamp_millis() -> i64 { pub fn timestamp_millis() -> i64 {
@ -6,3 +7,66 @@ pub fn timestamp_millis() -> i64 {
.unwrap() .unwrap()
.as_millis() as i64 .as_millis() as i64
} }
/// Track hash sizes in use across the crate.
pub mod hash_size {
use blake2::digest::{
consts::U64,
generic_array::ArrayLength,
typenum::{IsLessOrEqual, NonZero},
};
pub trait HashLen
where
<Self::Size as IsLessOrEqual<U64>>::Output: NonZero,
{
type Size: ArrayLength<u8> + IsLessOrEqual<U64>;
}
/// This macro generates HashLen for the given typenum::length
macro_rules! hash_sizes {
($($(#[$attr:meta])* $name:ident => $size:ty),* $(,)?) => {
$(
$(#[$attr])*
pub struct $name;
impl HashLen for $name { type Size = $size; }
)*
};
}
use blake2::digest::consts::{U6, U8};
hash_sizes! {
/// Account ID hash length
AccountId => U8,
/// Conversation ID hash length
ConvoId => U6,
}
}
/// This establishes an easy to use wrapper for hashes in this crate.
/// The output is formatted string of hex characters
pub fn blake2b_hex<LEN: hash_size::HashLen>(components: &[impl AsRef<[u8]>]) -> String {
//A
let mut hash = Blake2b::<LEN::Size>::new();
for c in components {
hash.update(c);
}
let output = hash.finalize();
hex::encode(output)
}
/// Shorten byte slices for testing and logging
#[allow(unused)]
pub fn hex_trunc(data: &[u8]) -> String {
if data.len() <= 8 {
hex::encode(data)
} else {
format!(
"{}..{}",
hex::encode(&data[..4]),
hex::encode(&data[data.len() - 4..])
)
}
}

View File

@ -0,0 +1,25 @@
[package]
name = "core_client"
version = "0.1.0"
edition = "2024"
[dependencies]
# Workspace dependencies (sorted)
blake2 = { workspace = true }
chat-sqlite = { workspace = true }
crypto = { workspace = true }
libchat = { workspace = true }
storage = { workspace = true }
# External dependencies (sorted)
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
thiserror = "2.0.18"
prost = "0.14.3"
hex = "0.4.3"
openmls = "0.8.1"
openmls_libcrux_crypto = "0.3.1"
openmls_memory_storage = "0.5.0"
openmls_traits = "0.5.0"

View File

@ -0,0 +1,67 @@
mod group_v1;
use crate::{AccountId, ContentData, DeliveryService, RegistrationService};
use chat_proto::logoschat::encryption::EncryptedPayload;
use libchat::IdentityProvider;
use std::fmt::Debug;
pub use crate::ChatError;
pub use group_v1::GroupV1Convo;
pub type ConversationIdRef<'a> = &'a str;
pub type ConversationId = String;
/// A trait which bundles all the external service traits into a single scope.
/// This allows for a single bound to be used internally, and cuts down on
/// the clutter
pub trait ExternalServices: Debug {
type IP: IdentityProvider;
type DS: DeliveryService;
type RS: RegistrationService;
}
#[derive(Debug)]
pub struct ServiceContext<S: ExternalServices> {
pub(crate) identity_provider: S::IP,
pub(crate) ds: S::DS,
pub(crate) rs: S::RS,
}
impl<S: ExternalServices> ServiceContext<S> {
pub fn new(identity_provider: S::IP, ds: S::DS, rs: S::RS) -> Self {
ServiceContext {
identity_provider,
ds,
rs,
}
}
}
pub trait Id: Debug {
fn id(&self) -> ConversationIdRef<'_>;
}
pub trait BaseConvo<S: ExternalServices>: Id + Debug {
fn init(&self, service_ctx: &mut ServiceContext<S>) -> Result<(), ChatError>;
fn send_content(
&mut self,
service_ctx: &mut ServiceContext<S>,
content: &[u8],
) -> Result<(), ChatError>;
fn handle_frame(
&mut self,
service_ctx: &mut ServiceContext<S>,
enc_payload: EncryptedPayload,
) -> Result<Option<ContentData>, ChatError>;
}
pub trait BaseGroupConvo<S: ExternalServices>: BaseConvo<S> {
fn add_member(
&mut self,
service_ctx: &mut ServiceContext<S>,
members: &[&AccountId],
) -> Result<(), ChatError>;
}

View File

@ -0,0 +1,323 @@
/// GroupV1 is a conversationType which provides effecient handling of multiple participants
/// Properties:
/// - Harvest Now Decrypt Later (HNDL) protection provided by XWING
/// - Multiple
use std::cell::RefCell;
use std::rc::Rc;
use blake2::{Blake2b, Digest, digest::consts::U6};
use chat_proto::logoschat::encryption::{EncryptedPayload, Plaintext, encrypted_payload};
use openmls::prelude::tls_codec::Deserialize;
use openmls::prelude::*;
use crate::AccountId;
use crate::conversation::{ConversationIdRef, ExternalServices, ServiceContext};
use crate::inbox_v2::{MlsIdentityProvider, MlsProvider};
use crate::{
AddressedEncryptedPayload, ContentData, DeliveryService, IdentityProvider, RegistrationService,
conversation::{BaseConvo, BaseGroupConvo, ChatError, Id},
};
pub struct GroupV1Convo<MP: MlsProvider> {
mls_provider: Rc<RefCell<MP>>,
mls_group: MlsGroup,
convo_id: String,
}
impl<MP: MlsProvider> std::fmt::Debug for GroupV1Convo<MP> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GroupV1Convo")
.field("convo_id", &self.convo_id)
.field("mls_epoch", &self.mls_group.epoch())
.finish_non_exhaustive()
}
}
impl<MP: MlsProvider> GroupV1Convo<MP> {
// Create a new conversation with the creator as the only participant.
pub fn new<IP: IdentityProvider>(
identity_provider: MlsIdentityProvider<IP>,
mls_provider: Rc<RefCell<MP>>,
) -> Result<Self, ChatError> {
let config = Self::mls_create_config();
let mls_group = {
let credential = identity_provider.get_credential();
MlsGroup::new(
&*mls_provider.borrow(),
&identity_provider,
&config,
credential,
)
.unwrap()
};
let convo_id = hex::encode(mls_group.group_id().as_slice());
Ok(Self {
mls_provider,
mls_group,
convo_id,
})
}
// Constructs a new conversation upon receiving a MlsWelcome message.
pub fn new_from_welcome(
mls_provider: Rc<RefCell<MP>>,
welcome: Welcome,
) -> Result<Self, ChatError> {
let mls_group = {
let provider = &*mls_provider.borrow();
StagedWelcome::build_from_welcome(provider, &Self::mls_join_config(), welcome)
.unwrap()
.build()
.unwrap()
.into_group(provider)
.unwrap()
};
let convo_id = hex::encode(mls_group.group_id().as_slice());
Ok(Self {
mls_provider,
mls_group,
convo_id,
})
}
fn mls_create_config() -> MlsGroupCreateConfig {
MlsGroupCreateConfig::builder()
.ciphersuite(Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519)
.use_ratchet_tree_extension(true) // This is handy for now, until there is central store for this data
.build()
}
fn mls_join_config() -> MlsGroupJoinConfig {
MlsGroupJoinConfig::builder().build()
}
fn delivery_address_from_id(convo_id: &str) -> String {
let hash = Blake2b::<U6>::new()
.chain_update("delivery_addr|")
.chain_update(convo_id)
.finalize();
hex::encode(hash)
}
fn delivery_address(&self) -> String {
Self::delivery_address_from_id(&self.convo_id)
}
fn ctrl_delivery_address_from_id(convo_id: &str) -> String {
let hash = Blake2b::<U6>::new()
.chain_update("ctrl_delivery_addr|")
.chain_update(convo_id)
.finalize();
hex::encode(hash)
}
fn ctrl_delivery_address(&self) -> String {
Self::ctrl_delivery_address_from_id(&self.convo_id)
}
}
impl<MP> Id for GroupV1Convo<MP>
where
MP: MlsProvider,
{
fn id(&self) -> ConversationIdRef<'_> {
&self.convo_id
}
}
impl<S, MP> BaseConvo<S> for GroupV1Convo<MP>
where
S: ExternalServices,
MP: MlsProvider,
{
fn init(&self, service_ctx: &mut super::ServiceContext<S>) -> Result<(), ChatError> {
// Configure the delivery service to listen for the required delivery addresses.
service_ctx
.ds
.subscribe(&Self::delivery_address_from_id(&self.convo_id))
.map_err(ChatError::generic)?;
service_ctx
.ds
.subscribe(&Self::ctrl_delivery_address_from_id(&self.convo_id))
.map_err(ChatError::generic)?;
Ok(())
}
fn send_content(
&mut self,
service_ctx: &mut super::ServiceContext<S>,
content: &[u8],
) -> Result<(), ChatError> {
let signer = MlsIdentityProvider(&service_ctx.identity_provider);
let mls_message_out = self
.mls_group
.create_message(&*self.mls_provider.borrow(), &signer, content)
.unwrap();
let payload = AddressedEncryptedPayload {
delivery_address: self.delivery_address(),
data: EncryptedPayload {
encryption: Some(encrypted_payload::Encryption::Plaintext(Plaintext {
payload: mls_message_out.to_bytes().unwrap().into(),
})),
},
};
let env = payload.into_envelope(self.id().into());
service_ctx
.ds
.publish(env)
.map_err(|e| ChatError::Delivery(e.to_string()))?;
Ok(())
}
fn handle_frame(
&mut self,
_service_ctx: &mut super::ServiceContext<S>,
encoded_payload: EncryptedPayload,
) -> Result<Option<ContentData>, ChatError> {
let bytes = match encoded_payload.encryption {
Some(encrypted_payload::Encryption::Plaintext(pt)) => pt.payload,
_ => {
return Err(ChatError::generic("Expected plaintext"));
}
};
let mls_message =
MlsMessageIn::tls_deserialize_exact_bytes(&bytes).map_err(ChatError::generic)?;
let protocol_message: ProtocolMessage = mls_message
.try_into_protocol_message()
.map_err(ChatError::generic)?;
let provider = &*self.mls_provider.borrow();
if protocol_message.epoch() < self.mls_group.epoch() {
// TODO: (P1) Add logging for messages arriving from past epoch.
return Ok(None);
}
let processed = self
.mls_group
.process_message(provider, protocol_message)
.map_err(ChatError::generic)?;
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(msg) => Ok(Some(ContentData {
conversation_id: hex::encode(self.mls_group.group_id().as_slice()),
data: msg.into_bytes(),
is_new_convo: false,
})),
ProcessedMessageContent::StagedCommitMessage(commit) => {
self.mls_group
.merge_staged_commit(provider, *commit)
.map_err(ChatError::generic)?;
Ok(None)
}
_ => {
// TODO: (P2) Log unknown message type
Ok(None)
}
}
}
}
impl<S, MP> BaseGroupConvo<S> for GroupV1Convo<MP>
where
S: ExternalServices,
MP: MlsProvider,
{
// add_members returns:
// commit — the Commit message Alice broadcasts to all members
// welcome — the Welcome message sent privately to each new joiner
// _group_info — used for external joins; ignore for now
fn add_member(
&mut self,
service_ctx: &mut ServiceContext<S>,
members: &[&AccountId],
) -> Result<(), ChatError> {
let mls_provider = &*self.mls_provider.borrow();
if members.len() > 50 {
// This is a temporary limit that originates from the the De-MLS epoch time.
return Err(ChatError::generic(
"Cannot add more than 50 Members at a time",
));
}
if members.is_empty() {
return Ok(());
}
// Get the Keypacakages and transpose any errors.
// The account_id is kept so invites can be addressed properly
let keypkgs = members
.iter()
.map(|ident| self.key_package_for_account(service_ctx, ident))
.collect::<Result<Vec<_>, ChatError>>()?;
let signer = MlsIdentityProvider(&service_ctx.identity_provider);
let (commit, welcome, _group_info) = self
.mls_group
.add_members(mls_provider, &signer, keypkgs.iter().as_slice())
.unwrap();
self.mls_group.merge_pending_commit(mls_provider).unwrap();
// TODO: (P3) Evaluate privacy/performance implications of an aggregated Welcome for multiple users
for account_id in members {
self.mls_provider
.borrow()
.invite_user(&mut service_ctx.ds, account_id, &welcome)?;
}
let encrypted_payload = EncryptedPayload {
encryption: Some(encrypted_payload::Encryption::Plaintext(Plaintext {
payload: commit.to_bytes().map_err(ChatError::generic)?.into(),
})),
};
let addr_enc_payload = AddressedEncryptedPayload {
delivery_address: self.ctrl_delivery_address(),
data: encrypted_payload,
};
// Prepare commit message
// TODO: (P1) Make GroupConvos agnostic to framing so its less error prone and more
let env = addr_enc_payload.into_envelope(self.convo_id.clone());
service_ctx
.ds
.publish(env)
.map_err(|e| ChatError::Generic(format!("Publish: {e}")))
}
}
impl<MP: MlsProvider> GroupV1Convo<MP> {
fn key_package_for_account<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
ident: &AccountId,
) -> Result<KeyPackage, ChatError> {
let retrieved_bytes = service_ctx
.rs
.retrieve(ident)
.map_err(|e| ChatError::Generic(e.to_string()))?;
// dbg!(ctx.contact_registry());
let Some(keypkg_bytes) = retrieved_bytes else {
return Err(ChatError::generic("Group Contact Not Found"));
};
let key_package_in = KeyPackageIn::tls_deserialize(&mut keypkg_bytes.as_slice())?;
let keypkg = key_package_in
.validate(self.mls_provider.borrow().crypto(), ProtocolVersion::Mls10)
.map_err(ChatError::generic)?; //TODO: P3 - Hardcoded Protocol Version
Ok(keypkg)
}
}

View File

@ -0,0 +1,282 @@
use std::cell::RefMut;
use std::collections::HashMap;
use std::fmt::Debug;
use std::{cell::RefCell, rc::Rc};
use crate::conversation::{
BaseGroupConvo, ConversationId, ConversationIdRef, ExternalServices, Id, ServiceContext,
};
use crate::inbox_v2::InboxV2;
use crate::{AccountId, errors::ChatError};
use crate::{DeliveryService, IdentityProvider, RegistrationService};
use chat_proto::logoschat::encryption::EncryptedPayload;
use chat_proto::logoschat::envelope::EnvelopeV1;
use libchat::ContentData;
use prost::Message;
use storage::ChatStore;
#[derive(Debug)]
enum ConvoTypeOwned<S: ExternalServices> {
// Pairwise(Box<dyn BaseConvo<S>>),
Group(Box<dyn BaseGroupConvo<S>>),
}
impl<S> Id for ConvoTypeOwned<S>
where
S: ExternalServices,
{
fn id(&self) -> crate::conversation::ConversationIdRef<'_> {
match self {
// ConvoTypeOwned::Pairwise(convo) => convo.id(),
ConvoTypeOwned::Group(convo) => convo.id(),
}
}
}
pub struct GroupConvo<S: ExternalServices, CS: ChatStore> {
client: Rc<RefCell<InnerClient<S, CS>>>,
convo_id: ConversationId,
}
impl<S, CS> GroupConvo<S, CS>
where
S: ExternalServices,
CS: ChatStore + 'static,
{
pub fn send_content(&self, content: &[u8]) -> Result<(), ChatError> {
let mut client = self.client.borrow_mut();
client.send_content(self.convo_id.as_str(), content)
}
}
// This allows the ExternalServices trait to be converted from a tuple.
// This is used in CoreClient to convert from the explicit impls to a
// ExternalServices bundle, which means it does not have to be exposed externally.
impl<IP, DS, RS> ExternalServices for (IP, DS, RS)
where
IP: IdentityProvider + Debug,
DS: DeliveryService + Debug,
RS: RegistrationService + Debug,
{
type IP = IP;
type DS = DS;
type RS = RS;
}
pub struct CoreClient<
IP: IdentityProvider,
DS: DeliveryService,
RS: RegistrationService,
CS: ChatStore,
> {
inner: Rc<RefCell<InnerClient<(IP, DS, RS), CS>>>,
}
impl<IP, DS, RS, CS> CoreClient<IP, DS, RS, CS>
where
IP: IdentityProvider,
DS: DeliveryService,
RS: RegistrationService,
CS: ChatStore + 'static,
{
pub fn new(account: IP, delivery: DS, registration: RS, store: CS) -> Result<Self, ChatError> {
let c = InnerClient::new(account, delivery, registration, store)?;
Ok(Self {
inner: Rc::new(RefCell::new(c)),
})
}
pub fn account_id(&self) -> AccountId {
self.inner.borrow().account_id().clone()
}
pub fn ds(&self) -> RefMut<'_, DS> {
RefMut::map(self.inner.borrow_mut(), |c| c.ds())
}
pub fn create_group_convo(
&self,
participants: &[&AccountId],
) -> Result<GroupConvo<(IP, DS, RS), CS>, ChatError> {
let convo_id = self.inner.borrow_mut().create_group_convo(participants)?;
Ok(GroupConvo {
client: self.inner.clone(),
convo_id,
})
}
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ChatError> {
self.inner.borrow().list_conversations()
}
pub fn send_content(
&self,
convo_id: ConversationIdRef,
content: &[u8],
) -> Result<(), ChatError> {
self.inner.borrow_mut().send_content(convo_id, content)
}
pub fn handle_payload(&self, payload: &[u8]) -> Result<Option<ContentData>, ChatError> {
self.inner.borrow_mut().handle_payload(payload)
}
pub fn convo(&self, convo_id: ConversationIdRef) -> Option<GroupConvo<(IP, DS, RS), CS>> {
let client = self.inner.clone();
if !client.borrow().has_conversation(convo_id) {
return None;
}
Some(GroupConvo {
client,
convo_id: convo_id.to_string(),
})
}
}
struct InnerClient<S: ExternalServices, CS: ChatStore> {
service_ctx: ServiceContext<S>,
_store: Rc<RefCell<CS>>,
pq_inbox: InboxV2<CS>,
// Cache of loaded conversations
cached_convos: HashMap<String, ConvoTypeOwned<S>>,
}
impl<S, CS> InnerClient<S, CS>
where
S: ExternalServices,
CS: ChatStore + 'static,
{
pub fn new(
account: S::IP,
delivery: S::DS,
registration: S::RS,
store: CS,
) -> Result<Self, ChatError> {
// Services for sharing with Converastions/Inboxes
// let mut service_ctx: ServiceContext<S> = ServiceContext::new(account, delivery, registration);
let mut service_ctx: ServiceContext<S> =
ServiceContext::new(account, delivery, registration);
// let contact_registry = Rc::new(RefCell::new(registration));
let _store = Rc::new(RefCell::new(store));
let pq_inbox = InboxV2::new(&mut service_ctx, _store.clone());
pq_inbox.register(&mut service_ctx)?;
// Subscribe
service_ctx
.ds
.subscribe(&pq_inbox.delivery_address())
.map_err(ChatError::generic)?;
Ok(Self {
service_ctx,
_store,
pq_inbox,
cached_convos: HashMap::new(),
})
}
pub fn ds(&mut self) -> &mut S::DS {
&mut self.service_ctx.ds
}
/// Returns the unique identifier associated with the account
pub fn account_id(&self) -> &AccountId {
self.pq_inbox.account_id()
}
pub fn create_group_convo(&mut self, participants: &[&AccountId]) -> Result<String, ChatError> {
let convo = self.pq_inbox.create_group_v1(&mut self.service_ctx)?;
let mut convo: Box<dyn BaseGroupConvo<S>> = Box::new(convo);
convo.init(&mut self.service_ctx)?;
convo.add_member(&mut self.service_ctx, participants)?;
let convo_id = convo.id().to_string();
self.register_convo(ConvoTypeOwned::Group(convo))?;
Ok(convo_id)
}
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ChatError> {
Ok(self.cached_convos.keys().cloned().collect())
}
pub fn has_conversation(&self, convo_id: ConversationIdRef) -> bool {
self.cached_convos.contains_key(convo_id)
}
pub fn send_content(
&mut self,
convo_id: ConversationIdRef,
content: &[u8],
) -> Result<(), ChatError> {
let Some(convo) = self.cached_convos.get_mut(convo_id) else {
return Err(ChatError::generic("No Convo Found"));
};
let convo = match convo {
// ConvoTypeOwned::Pairwise(_) => todo!(),
ConvoTypeOwned::Group(c) => c.as_mut(),
};
convo.send_content(&mut self.service_ctx, content)
}
// Decode bytes and send to protocol for processing.
pub fn handle_payload(&mut self, payload: &[u8]) -> Result<Option<ContentData>, ChatError> {
let env = EnvelopeV1::decode(payload)?;
// TODO: Impl Conversation hinting
let convo_id = env.conversation_hint;
match convo_id {
c if c == self.pq_inbox.id() => self.dispatch_to_inbox2(&env.payload),
c if self.cached_convos.contains_key(c.as_str()) => {
self.dispatch_to_convo(c, &env.payload)
}
_ => Ok(None),
}
}
// Dispatch encrypted payload to Inbox, and register the created Conversation
fn dispatch_to_inbox2(&mut self, payload: &[u8]) -> Result<Option<ContentData>, ChatError> {
if let Some(convo) = self.pq_inbox.handle_frame(&mut self.service_ctx, payload)? {
let convo: Box<dyn BaseGroupConvo<S>> = Box::new(convo);
self.register_convo(ConvoTypeOwned::Group(convo))?;
}
Ok(None)
}
// Dispatch encrypted payload to its corresponding conversation
fn dispatch_to_convo(
&mut self,
convo_id: ConversationId,
enc_payload_bytes: &[u8],
) -> Result<Option<ContentData>, ChatError> {
let enc_payload = EncryptedPayload::decode(enc_payload_bytes)?;
let Some(convo) = self.cached_convos.get_mut(&convo_id) else {
return Err(ChatError::generic("No Convo Found"));
};
let convo = match convo {
// ConvoTypeOwned::Pairwise(_) => todo!(),
ConvoTypeOwned::Group(c) => c.as_mut(),
};
convo.handle_frame(&mut self.service_ctx, enc_payload)
}
fn register_convo(&mut self, convo: ConvoTypeOwned<S>) -> Result<(), ChatError> {
let res = self.cached_convos.insert(convo.id().to_string(), convo);
match res {
Some(_) => Err(ChatError::generic("Convo already exists. Cannot save")),
None => Ok(()),
}
}
}

View File

@ -0,0 +1,21 @@
use openmls::prelude::tls_codec;
pub use thiserror::Error;
#[derive(Error, Debug)]
pub enum ChatError {
#[error("generic: {0}")]
Generic(String),
#[error("TlsCodec: {0}")]
TlsCodec(#[from] tls_codec::Error),
#[error("Protobuf decode: {0}")]
ProtobufDecodeError(#[from] prost::DecodeError),
#[error("delivery: {0}")]
Delivery(String),
}
impl ChatError {
// This is a stopgap until there is a proper error system in place
pub fn generic(e: impl ToString) -> Self {
Self::Generic(e.to_string())
}
}

View File

@ -0,0 +1,315 @@
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc;
use chat_proto::logoschat::envelope::EnvelopeV1;
use openmls::prelude::tls_codec::Serialize;
use openmls::prelude::*;
use openmls_libcrux_crypto::CryptoProvider as LibcruxCryptoProvider;
use openmls_memory_storage::MemoryStorage;
use openmls_traits::signatures::Signer;
use openmls_traits::signatures::SignerError;
use prost::{Message, Oneof};
use storage::ChatStore;
use storage::ConversationMeta;
use crate::AccountId;
use crate::AddressedEnvelope;
use crate::ChatError;
use crate::DeliveryService;
use crate::IdentityProvider;
use crate::RegistrationService;
use crate::conversation::BaseConvo;
use crate::conversation::ExternalServices;
use crate::conversation::ServiceContext;
use crate::conversation::{GroupV1Convo, Id};
use crate::utils::{blake2b_hex, hash_size};
// Define unique Identifiers derivations used in InboxV2
fn delivery_address_for(account_id: &AccountId) -> String {
blake2b_hex::<hash_size::AccountId>(&["InboxV2|", "delivery_address|", account_id.as_str()])
}
fn conversation_id_for(account_id: &AccountId) -> String {
blake2b_hex::<hash_size::ConvoId>(&["InboxV2|", "conversation_id|", account_id.as_str()])
}
#[derive(Debug)]
pub struct MlsIdentityProvider<T: IdentityProvider>(pub T);
impl<T: IdentityProvider> MlsIdentityProvider<T> {
pub fn get_credential(&self) -> CredentialWithKey {
CredentialWithKey {
credential: BasicCredential::new(self.0.friendly_name().into()).into(),
signature_key: self.0.public_key().as_ref().into(),
}
}
}
impl<T: IdentityProvider> Deref for MlsIdentityProvider<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T: IdentityProvider> IdentityProvider for MlsIdentityProvider<T> {
fn account_id(&self) -> &AccountId {
self.0.account_id()
}
fn friendly_name(&self) -> String {
self.0.friendly_name()
}
fn sign(&self, payload: &[u8]) -> crypto::Ed25519Signature {
self.0.sign(payload)
}
fn public_key(&self) -> &crypto::Ed25519VerifyingKey {
self.0.public_key()
}
}
impl<T: IdentityProvider> Signer for MlsIdentityProvider<T> {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, SignerError> {
Ok(self.0.sign(payload).as_ref().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}
/// An Extension trait which extends OpenMlsProvider to add required functionality
/// All MLS based Conversation should use this trait for defining requirements.
pub trait MlsProvider: OpenMlsProvider {
fn invite_user<DS: DeliveryService>(
&self,
ds: &mut DS,
account_id: &AccountId,
welcome: &MlsMessageOut,
) -> Result<(), ChatError>;
}
/// This is a PQ based provider that uses in memory storage.
pub struct MlsEphemeralPqProvider {
crypto: LibcruxCryptoProvider,
storage: MemoryStorage,
}
impl MlsEphemeralPqProvider {
pub fn new() -> Result<Self, CryptoError> {
let crypto = LibcruxCryptoProvider::new()?;
let storage = MemoryStorage::default();
Ok(Self { crypto, storage })
}
}
impl MlsProvider for MlsEphemeralPqProvider {
fn invite_user<DS: DeliveryService>(
&self,
ds: &mut DS,
account_id: &AccountId,
welcome: &MlsMessageOut,
) -> Result<(), ChatError> {
let invite = GroupV1HeavyInvite {
welcome_bytes: welcome.to_bytes().map_err(ChatError::generic)?,
};
let frame = InboxV2Frame {
payload: Some(InviteType::GroupV1(invite)),
};
let envelope = EnvelopeV1 {
conversation_hint: conversation_id_for(account_id),
salt: 0,
payload: frame.encode_to_vec().into(),
};
let outbound_msg = AddressedEnvelope {
delivery_address: delivery_address_for(account_id),
data: envelope.encode_to_vec(),
};
ds.publish(outbound_msg).map_err(ChatError::generic)?;
Ok(())
}
}
impl OpenMlsProvider for MlsEphemeralPqProvider {
type CryptoProvider = LibcruxCryptoProvider;
type RandProvider = LibcruxCryptoProvider;
type StorageProvider = openmls_memory_storage::MemoryStorage;
fn storage(&self) -> &Self::StorageProvider {
&self.storage
}
fn crypto(&self) -> &Self::CryptoProvider {
&self.crypto
}
fn rand(&self) -> &Self::RandProvider {
&self.crypto
}
}
/// An PQ focused Conversation initializer.
/// InboxV2 Incorporates an Account based identity system to support PQ based conversation protocols
/// such as MLS.
pub struct InboxV2<CS> {
account_id: AccountId,
_store: Rc<RefCell<CS>>,
mls_provider: Rc<RefCell<MlsEphemeralPqProvider>>,
}
impl<CS: ChatStore> InboxV2<CS> {
pub fn new<S: ExternalServices>(
service_ctx: &mut ServiceContext<S>,
_store: Rc<RefCell<CS>>,
) -> Self {
// Avoid referencing a temporary value by caching it.
let account_id = service_ctx.identity_provider.account_id().clone();
let provider = MlsEphemeralPqProvider::new().unwrap();
Self {
account_id,
_store,
mls_provider: Rc::new(RefCell::new(provider)),
}
}
pub fn account_id(&self) -> &AccountId {
&self.account_id
}
pub fn delivery_address(&self) -> String {
delivery_address_for(&self.account_id)
}
pub fn id(&self) -> String {
conversation_id_for(&self.account_id)
}
/// Submit MlsKeypackage to registration service
pub fn register<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
) -> Result<(), ChatError> {
let mls_ident = MlsIdentityProvider(&service_ctx.identity_provider);
let keypackage_bytes = self
.create_keypackage(&mls_ident)?
.tls_serialize_detached()?;
// TODO: (P3) Each keypackage can only be used once either enable...
// "LastResort" package or publish multiple
service_ctx
.rs
.register(
&service_ctx.identity_provider.friendly_name(),
keypackage_bytes,
)
.map_err(ChatError::generic)
}
pub fn create_group_v1<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
) -> Result<GroupV1Convo<MlsEphemeralPqProvider>, ChatError> {
let mls_ident = MlsIdentityProvider(&service_ctx.identity_provider);
GroupV1Convo::new(mls_ident, self.mls_provider.clone())
}
fn create_keypackage<IP: IdentityProvider>(
&self,
signer: &MlsIdentityProvider<IP>,
) -> Result<KeyPackage, ChatError> {
let capabilities = Capabilities::builder()
.ciphersuites(vec![
Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519,
])
.extensions(vec![ExtensionType::ApplicationId])
.build();
let a = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
Ciphersuite::MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519,
&*self.mls_provider.borrow(),
signer,
signer.get_credential(),
)
.expect("Failed to build KeyPackage");
Ok(a.key_package().clone())
}
}
impl<CS: ChatStore> InboxV2<CS> {
pub fn handle_frame<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
payload_bytes: &[u8],
) -> Result<Option<GroupV1Convo<MlsEphemeralPqProvider>>, ChatError> {
let inbox_frame = InboxV2Frame::decode(payload_bytes)?;
let Some(payload) = inbox_frame.payload else {
return Err(ChatError::Generic("InboxV2Payload missing".into()));
};
match payload {
InviteType::GroupV1(group_v1_heavy_invite) => self
.handle_heavy_invite(service_ctx, group_v1_heavy_invite)
.map(Some),
}
}
fn handle_heavy_invite<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
invite: GroupV1HeavyInvite,
) -> Result<GroupV1Convo<MlsEphemeralPqProvider>, ChatError> {
let (msg_in, _rest) = MlsMessageIn::tls_deserialize_bytes(invite.welcome_bytes.as_slice())?;
let MlsMessageBodyIn::Welcome(welcome) = msg_in.extract() else {
return Err(ChatError::Generic("Expected Welcome".into()));
};
let convo = GroupV1Convo::new_from_welcome(self.mls_provider.clone(), welcome)?;
convo.init(service_ctx)?;
self.persist_convo(convo.id())?;
Ok(convo)
}
fn persist_convo(&self, local_convo_id: &str) -> Result<(), ChatError> {
let meta = ConversationMeta {
local_convo_id: local_convo_id.to_string(),
remote_convo_id: "0".into(),
kind: storage::ConversationKind::GroupV1,
};
self._store
.borrow_mut()
.save_conversation(&meta)
.map_err(ChatError::generic)
}
}
#[derive(Clone, PartialEq, Message)]
pub struct InboxV2Frame {
#[prost(oneof = "InviteType", tags = "1")]
pub payload: Option<InviteType>,
}
#[derive(Clone, PartialEq, Oneof)]
pub enum InviteType {
#[prost(message, tag = "1")]
GroupV1(GroupV1HeavyInvite),
}
#[derive(Clone, PartialEq, Message)]
pub struct GroupV1HeavyInvite {
#[prost(bytes, tag = "1")]
pub welcome_bytes: Vec<u8>,
}

View File

@ -0,0 +1,13 @@
mod conversation;
mod core_client;
mod errors;
mod inbox_v2;
mod utils;
pub use libchat::{
AccountId, AddressedEncryptedPayload, AddressedEnvelope, ContentData, DeliveryService,
IdentityProvider, RegistrationService,
};
pub use core_client::{CoreClient, GroupConvo};
pub use errors::ChatError;

View File

@ -0,0 +1,64 @@
use blake2::{Blake2b, Digest};
/// Track hash sizes in use across the crate.
pub mod hash_size {
use blake2::digest::{
consts::U64,
generic_array::ArrayLength,
typenum::{IsLessOrEqual, NonZero},
};
pub trait HashLen
where
<Self::Size as IsLessOrEqual<U64>>::Output: NonZero,
{
type Size: ArrayLength<u8> + IsLessOrEqual<U64>;
}
/// This macro generates HashLen for the given typenum::length
macro_rules! hash_sizes {
($($(#[$attr:meta])* $name:ident => $size:ty),* $(,)?) => {
$(
$(#[$attr])*
pub struct $name;
impl HashLen for $name { type Size = $size; }
)*
};
}
use blake2::digest::consts::{U6, U8};
hash_sizes! {
/// Account ID hash length
AccountId => U8,
/// Conversation ID hash length
ConvoId => U6,
}
}
/// This establishes an easy to use wrapper for hashes in this crate.
/// The output is formatted string of hex characters
pub fn blake2b_hex<LEN: hash_size::HashLen>(components: &[impl AsRef<[u8]>]) -> String {
//A
let mut hash = Blake2b::<LEN::Size>::new();
for c in components {
hash.update(c);
}
let output = hash.finalize();
hex::encode(output)
}
/// Shorten byte slices for testing and logging
#[allow(unused)]
pub fn hex_trunc(data: &[u8]) -> String {
if data.len() <= 8 {
hex::encode(data)
} else {
format!(
"{}..{}",
hex::encode(&data[..4]),
hex::encode(&data[data.len() - 4..])
)
}
}

View File

@ -4,12 +4,13 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } # External dependencies (sorted)
hkdf = "0.12"
sha2 = "0.10"
rand_core = { version = "0.6", features = ["getrandom"] }
ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } ed25519-dalek = { version = "2.2.0", features = ["rand_core"] }
xeddsa = "1.0.2"
zeroize = {version = "1.8.2", features= ["derive"]}
generic-array = "1.3.5" generic-array = "1.3.5"
hkdf = "0.12"
rand_core = { version = "0.6", features = ["getrandom"] }
sha2 = "0.10"
thiserror = "2" thiserror = "2"
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
xeddsa = "1.0.2"
zeroize = { version = "1.8.2", features = ["derive"] }

View File

@ -6,6 +6,6 @@ mod xeddsa_sign;
pub use identity::Identity; pub use identity::Identity;
pub use keys::{PrivateKey, PublicKey, SymmetricKey32}; pub use keys::{PrivateKey, PublicKey, SymmetricKey32};
pub use signatures::{Ed25519SigningKey, Ed25519VerifyingKey}; pub use signatures::{Ed25519Signature, Ed25519SigningKey, Ed25519VerifyingKey};
pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake}; pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake};
pub use xeddsa_sign::{Ed25519Signature, SignatureError, xeddsa_sign, xeddsa_verify}; pub use xeddsa_sign::{SignatureError, XedDsaSignature, xeddsa_sign, xeddsa_verify};

View File

@ -5,14 +5,14 @@ use rand_core::{CryptoRng, RngCore};
use sha2::Sha256; use sha2::Sha256;
use crate::keys::{PrivateKey, PublicKey, SymmetricKey32}; use crate::keys::{PrivateKey, PublicKey, SymmetricKey32};
use crate::xeddsa_sign::Ed25519Signature; use crate::xeddsa_sign::XedDsaSignature;
/// A prekey bundle containing the public keys needed to initiate an X3DH key exchange. /// A prekey bundle containing the public keys needed to initiate an X3DH key exchange.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PrekeyBundle { pub struct PrekeyBundle {
pub identity_key: PublicKey, pub identity_key: PublicKey,
pub signed_prekey: PublicKey, pub signed_prekey: PublicKey,
pub signature: Ed25519Signature, pub signature: XedDsaSignature,
pub onetime_prekey: Option<PublicKey>, pub onetime_prekey: Option<PublicKey>,
} }
@ -151,7 +151,7 @@ mod tests {
let bob_bundle = PrekeyBundle { let bob_bundle = PrekeyBundle {
identity_key: bob_identity_pub, identity_key: bob_identity_pub,
signed_prekey: bob_signed_prekey_pub, signed_prekey: bob_signed_prekey_pub,
signature: Ed25519Signature::empty(), signature: XedDsaSignature::empty(),
onetime_prekey: Some(bob_onetime_prekey_pub), onetime_prekey: Some(bob_onetime_prekey_pub),
}; };
@ -191,7 +191,7 @@ mod tests {
let bob_bundle = PrekeyBundle { let bob_bundle = PrekeyBundle {
identity_key: bob_identity_pub, identity_key: bob_identity_pub,
signed_prekey: bob_signed_prekey_pub, signed_prekey: bob_signed_prekey_pub,
signature: Ed25519Signature::empty(), signature: XedDsaSignature::empty(),
onetime_prekey: None, onetime_prekey: None,
}; };

View File

@ -9,21 +9,21 @@ use xeddsa::{Sign, Verify, xed25519};
use crate::{PrivateKey, PublicKey}; use crate::{PrivateKey, PublicKey};
/// A 64-byte XEdDSA signature over an Ed25519-compatible curve. /// A 64-byte XEdDSA signature over an Ed25519-compatible curve.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Ed25519Signature(pub [u8; 64]); pub struct XedDsaSignature(pub [u8; 64]);
impl Ed25519Signature { impl XedDsaSignature {
pub fn empty() -> Self { pub fn empty() -> Self {
Self([0u8; 64]) Self([0u8; 64])
} }
} }
impl AsRef<[u8; 64]> for Ed25519Signature { impl AsRef<[u8; 64]> for XedDsaSignature {
fn as_ref(&self) -> &[u8; 64] { fn as_ref(&self) -> &[u8; 64] {
&self.0 &self.0
} }
} }
impl From<[u8; 64]> for Ed25519Signature { impl From<[u8; 64]> for XedDsaSignature {
fn from(bytes: [u8; 64]) -> Self { fn from(bytes: [u8; 64]) -> Self {
Self(bytes) Self(bytes)
} }
@ -47,9 +47,9 @@ pub fn xeddsa_sign<R: RngCore + CryptoRng>(
secret: &PrivateKey, secret: &PrivateKey,
message: &[u8], message: &[u8],
mut rng: R, mut rng: R,
) -> Ed25519Signature { ) -> XedDsaSignature {
let signing_key = xed25519::PrivateKey::from(secret); let signing_key = xed25519::PrivateKey::from(secret);
Ed25519Signature(signing_key.sign(message, &mut rng)) XedDsaSignature(signing_key.sign(message, &mut rng))
} }
/// Verify an XEdDSA signature using an X25519 public key. /// Verify an XEdDSA signature using an X25519 public key.
@ -64,7 +64,7 @@ pub fn xeddsa_sign<R: RngCore + CryptoRng>(
pub fn xeddsa_verify( pub fn xeddsa_verify(
pubkey: &PublicKey, pubkey: &PublicKey,
message: &[u8], message: &[u8],
signature: &Ed25519Signature, signature: &XedDsaSignature,
) -> Result<(), SignatureError> { ) -> Result<(), SignatureError> {
let verify_key = xed25519::PublicKey::from(pubkey); let verify_key = xed25519::PublicKey::from(pubkey);
verify_key verify_key

View File

@ -7,17 +7,23 @@ edition = "2024"
crate-type = ["rlib", "cdylib"] crate-type = ["rlib", "cdylib"]
[dependencies] [dependencies]
x25519-dalek = { version="2.0.1", features=["static_secrets"] } # Workspace dependencies (sorted)
chacha20poly1305 = "0.10.1"
rand_core = "0.6.4"
rand = "0.9.3"
hkdf = "0.12.4"
thiserror = "2"
blake2 = "0.10.6"
zeroize = "1.8.2"
storage = { workspace = true } storage = { workspace = true }
# External dependencies (sorted)
blake2 = "0.10.6"
chacha20poly1305 = "0.10.1"
hkdf = "0.12.4"
rand = "0.9.3"
rand_core = "0.6.4"
serde = "1.0" serde = "1.0"
thiserror = "2"
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
zeroize = "1.8.2"
[dev-dependencies] [dev-dependencies]
sqlite = { package = "chat-sqlite", path = "../sqlite" } # Workspace dependencies (sorted)
chat-sqlite = { workspace = true }
# External dependencies (sorted)
tempfile = "3" tempfile = "3"

View File

@ -2,8 +2,8 @@
//! //!
//! Run with: cargo run --example out_of_order_demo -p double-ratchets //! Run with: cargo run --example out_of_order_demo -p double-ratchets
use chat_sqlite::{ChatStorage, StorageConfig};
use double_ratchets::{InstallationKeyPair, RatchetSession}; use double_ratchets::{InstallationKeyPair, RatchetSession};
use sqlite::{ChatStorage, StorageConfig};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
fn main() { fn main() {

View File

@ -2,8 +2,8 @@
//! //!
//! Run with: cargo run --example storage_demo -p double-ratchets //! Run with: cargo run --example storage_demo -p double-ratchets
use chat_sqlite::{ChatStorage, StorageConfig};
use double_ratchets::{InstallationKeyPair, RatchetSession}; use double_ratchets::{InstallationKeyPair, RatchetSession};
use sqlite::{ChatStorage, StorageConfig};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
fn main() { fn main() {

View File

@ -168,7 +168,7 @@ fn save_state<S: RatchetStore, D: HkdfInfo>(
mod tests { mod tests {
use super::*; use super::*;
use crate::hkdf::DefaultDomain; use crate::hkdf::DefaultDomain;
use sqlite::ChatStorage; use chat_sqlite::ChatStorage;
fn create_test_storage() -> ChatStorage { fn create_test_storage() -> ChatStorage {
ChatStorage::in_memory() ChatStorage::in_memory()

View File

@ -0,0 +1,20 @@
[package]
name = "integration_tests_core"
version = "0.1.0"
edition = "2024"
# [[test]]
# name = "integration_tests_core"
[dev-dependencies]
# Workspace dependencies (sorted)
chat-sqlite = { workspace = true }
components = { workspace = true }
libchat = { workspace = true }
logos-account = { workspace = true, features = ["dev"]}
storage = { workspace = true }
core_client = {path = "../core_client"}
# External dependencies (sorted)
tempfile = "3"

View File

@ -0,0 +1,12 @@
This crate is dedicated to backend integration tests.
Tests can be built using any supplied service implementation.
Various implementations are available in the `Extensions/components` crate.
## Running Tests
Integration tests are executed when running `cargo test` from the workspace folder.
Alternatively they can be executed from any crate, using
`cargo test --package integration_tests_core`

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,104 @@
use std::ops::{Deref, DerefMut};
use components::{EphemeralRegistry, LocalBroadcaster, MemStore};
use core_client::{ChatError, CoreClient};
use libchat::{ContentData, hex_trunc};
use logos_account::TestLogosAccount;
struct PollableClient {
inner: CoreClient<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, MemStore>,
on_content: Option<Box<dyn Fn(ContentData)>>,
}
impl PollableClient {
fn init(
ctx: CoreClient<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, MemStore>,
cb: Option<impl Fn(ContentData) + 'static>,
) -> Self {
Self {
inner: ctx,
on_content: cb.map(|f| Box::new(f) as Box<dyn Fn(ContentData)>),
}
}
fn process_messages(&mut self) {
let messages = self.inner.ds().poll_all();
for data in messages {
let res = self.handle_payload(&data).unwrap();
if let Some(cb) = &self.on_content
&& let Some(content_data) = res
{
cb(content_data);
}
}
}
}
impl Deref for PollableClient {
type Target = CoreClient<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, MemStore>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for PollableClient {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
fn process(clients: &mut Vec<PollableClient>) {
for client in clients {
client.process_messages();
}
}
// Higher order function to handle printing
fn pretty_print(prefix: impl Into<String>) -> Box<dyn Fn(ContentData)> {
let prefix = prefix.into();
Box::new(move |c: ContentData| {
let cid = hex_trunc(c.conversation_id.as_bytes());
let content = String::from_utf8(c.data).unwrap();
println!("{} ({:?}) {}", prefix, cid, content)
})
}
#[test]
fn core_client() {
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let saro_account = TestLogosAccount::new("saro");
let raya_account = TestLogosAccount::new("raya");
let saro = CoreClient::new(saro_account, ds.clone(), rs.clone(), MemStore::new()).unwrap();
let raya = CoreClient::new(raya_account, ds, rs, MemStore::new()).unwrap();
let mut clients = vec![
PollableClient::init(saro, Some(pretty_print(" Saro "))),
PollableClient::init(raya, Some(pretty_print(" Raya "))),
];
const SARO: usize = 0;
const RAYA: usize = 1;
let s_convo = clients[SARO]
.create_group_convo(&[&clients[RAYA].account_id()])
.unwrap();
process(&mut clients);
s_convo.send_content(b"HI").unwrap();
let convo_id = clients[RAYA].list_conversations().unwrap().pop().unwrap();
let r_convo = clients[RAYA].convo(&convo_id).expect("Convo exists");
process(&mut clients);
r_convo.send_content(b"PEW").unwrap();
process(&mut clients);
s_convo.send_content(b"SARO again").unwrap();
process(&mut clients);
println!("Hello");
}

View File

@ -0,0 +1,160 @@
use std::ops::{Deref, DerefMut};
use components::{EphemeralRegistry, LocalBroadcaster, MemStore};
use libchat::{ContentData, Context, GroupConvo, hex_trunc};
use logos_account::TestLogosAccount;
// Simple client Functionality for testing
struct Client {
inner: Context<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, MemStore>,
on_content: Option<Box<dyn Fn(ContentData)>>,
}
impl Client {
fn init(
ctx: Context<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, MemStore>,
cb: Option<impl Fn(ContentData) + 'static>,
) -> Self {
Client {
inner: ctx,
on_content: cb.map(|f| Box::new(f) as Box<dyn Fn(ContentData)>),
}
}
fn process_messages(&mut self) {
let messages: Vec<_> = {
let mut ds = self.ds();
std::iter::from_fn(|| ds.poll()).collect()
};
for data in messages {
let res = self.handle_payload(&data).unwrap();
if let Some(cb) = &self.on_content
&& let Some(content_data) = res
{
cb(content_data);
}
}
}
fn convo(
&mut self,
convo_id: &str,
) -> Box<dyn GroupConvo<LocalBroadcaster, EphemeralRegistry>> {
// TODO: (P1) Convos are being copied somewhere, which means hanging on to a reference causes state desync
self.get_convo(convo_id).unwrap()
}
}
impl Deref for Client {
type Target = Context<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, MemStore>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Client {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
// Higher order function to handle printing
fn pretty_print(prefix: impl Into<String>) -> Box<dyn Fn(ContentData)> {
let prefix = prefix.into();
Box::new(move |c: ContentData| {
let cid = hex_trunc(c.conversation_id.as_bytes());
let content = String::from_utf8(c.data).unwrap();
println!("{} ({:?}) {}", prefix, cid, content)
})
}
fn process(clients: &mut Vec<Client>) {
for client in clients {
client.process_messages();
}
}
#[test]
fn create_group() {
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let saro_account = TestLogosAccount::new("saro");
let saro_ctx = Context::new_with_name(
"saro",
saro_account,
ds.new_consumer(),
rs.clone(),
MemStore::new(),
)
.unwrap();
let raya_account = TestLogosAccount::new("raya");
let raya_ctx = Context::new_with_name(
"raya",
raya_account,
ds.clone(),
rs.clone(),
MemStore::new(),
)
.unwrap();
let mut clients = vec![
Client::init(saro_ctx, Some(pretty_print(" Saro "))),
Client::init(raya_ctx, Some(pretty_print(" Raya "))),
];
const SARO: usize = 0;
const RAYA: usize = 1;
let raya_id = clients[RAYA].account_id().clone();
let s_convo = clients[SARO].create_group_convo(&[&raya_id]).unwrap();
let convo_id = s_convo.id();
// Raya can read this message because
// 1) It was sent after add_members was committed, and
// 2) LocalBroadcaster provides historical messages.
clients[SARO]
.convo(convo_id)
.send_content(b"ok who broke the group chat again")
.unwrap();
process(&mut clients);
clients[RAYA]
.convo(convo_id)
.send_content(b"it was literally working five minutes ago")
.unwrap();
process(&mut clients);
let pax_account = TestLogosAccount::new("pax");
let pax_ctx = Context::new_with_name("pax", pax_account, ds, rs, MemStore::new()).unwrap();
clients.push(Client::init(pax_ctx, Some(pretty_print(" Pax"))));
const PAX: usize = 2;
let pax_id = clients[PAX].account_id().clone();
clients[SARO]
.convo(convo_id)
.add_member(&[&pax_id])
.unwrap();
process(&mut clients);
clients[PAX]
.convo(convo_id)
.send_content(b"ngl the key rotation is cooked")
.unwrap();
process(&mut clients);
clients[SARO]
.convo(convo_id)
.send_content(b"bro we literally just added you to the group ")
.unwrap();
process(&mut clients);
}

View File

@ -0,0 +1,194 @@
use chat_sqlite::{ChatStorage, StorageConfig};
use components::{EphemeralRegistry, LocalBroadcaster};
use libchat::{Context, Introduction};
use logos_account::TestLogosAccount;
use storage::{ConversationStore, IdentityStore};
use tempfile::tempdir;
fn send_and_verify(
sender: &mut Context<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, ChatStorage>,
receiver: &mut Context<TestLogosAccount, LocalBroadcaster, EphemeralRegistry, ChatStorage>,
convo_id: &str,
content: &[u8],
) {
let payloads = sender.send_content(convo_id, content).unwrap();
let payload = payloads.first().unwrap();
let received = receiver
.handle_payload(&payload.data)
.unwrap()
.expect("expected content");
assert_eq!(content, received.data.as_slice());
assert!(!received.is_new_convo);
}
#[test]
fn ctx_integration() {
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let saro_account = TestLogosAccount::new("saro");
let raya_account = TestLogosAccount::new("raya");
let mut saro = Context::new_with_name(
"saro",
saro_account,
ds.clone(),
rs.clone(),
ChatStorage::in_memory(),
)
.unwrap();
let mut raya =
Context::new_with_name("raya", raya_account, ds, rs, ChatStorage::in_memory()).unwrap();
// Raya creates intro bundle and sends to Saro
let bundle = raya.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).unwrap();
// Saro initiates conversation with Raya
let mut content = vec![10];
let (saro_convo_id, payloads) = saro.create_private_convo(&intro, &content).unwrap();
// Raya receives initial message
let payload = payloads.first().unwrap();
let initial_content = raya
.handle_payload(&payload.data)
.unwrap()
.expect("expected initial content");
let raya_convo_id = initial_content.conversation_id;
assert_eq!(content, initial_content.data);
assert!(initial_content.is_new_convo);
// Exchange messages back and forth
for _ in 0..10 {
content.push(content.last().unwrap() + 1);
send_and_verify(&mut raya, &mut saro, &raya_convo_id, &content);
content.push(content.last().unwrap() + 1);
send_and_verify(&mut saro, &mut raya, &saro_convo_id, &content);
}
}
#[test]
fn identity_persistence() {
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let store1 = ChatStorage::new(StorageConfig::InMemory).unwrap();
let account = TestLogosAccount::new("saro");
let ctx1 = Context::new_with_name("saro", account, ds, rs, store1).unwrap();
let pubkey1 = ctx1.identity().public_key();
let name1 = ctx1.installation_name().to_string();
// For persistence tests with file-based storage, we'd need a shared db.
// With in-memory, we just verify the identity was created.
assert_eq!(name1, "saro");
assert!(!pubkey1.as_bytes().iter().all(|&b| b == 0));
}
#[test]
fn open_persists_new_identity() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("chat.sqlite");
let db_path = db_path.to_string_lossy().into_owned();
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let store = ChatStorage::new(StorageConfig::File(db_path.clone())).unwrap();
let account = TestLogosAccount::new("saro");
let ctx = Context::new_from_store("saro", account, ds, rs, store).unwrap();
let pubkey = ctx.identity().public_key();
drop(ctx);
let store = ChatStorage::new(StorageConfig::File(db_path)).unwrap();
let persisted = store.load_identity().unwrap().unwrap();
assert_eq!(persisted.get_name(), "saro");
assert_eq!(persisted.public_key(), pubkey);
}
#[test]
fn conversation_metadata_persistence() {
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let account_saro = TestLogosAccount::new("saro");
let mut saro = Context::new_with_name(
"saro",
account_saro,
ds.clone(),
rs.clone(),
ChatStorage::in_memory(),
)
.unwrap();
let account_raya = TestLogosAccount::new("raya");
let mut raya =
Context::new_with_name("raya", account_raya, ds, rs, ChatStorage::in_memory()).unwrap();
let bundle = saro.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).unwrap();
let (_, payloads) = raya.create_private_convo(&intro, b"hi").unwrap();
let payload = payloads.first().unwrap();
let content = saro.handle_payload(&payload.data).unwrap().unwrap();
assert!(content.is_new_convo);
let convos = saro.store().load_conversations().unwrap();
assert_eq!(convos.len(), 1);
assert_eq!(convos[0].kind.as_str(), "private_v1");
}
#[test]
fn conversation_full_flow() {
let ds = LocalBroadcaster::new();
let rs = EphemeralRegistry::new();
let account_saro = TestLogosAccount::new("saro");
let account_raya = TestLogosAccount::new("raya");
let mut saro = Context::new_with_name(
"saro",
account_saro,
ds.clone(),
rs.clone(),
ChatStorage::in_memory(),
)
.unwrap();
let mut raya =
Context::new_with_name("raya", account_raya, ds, rs, ChatStorage::in_memory()).unwrap();
let bundle = saro.create_intro_bundle().unwrap();
let intro = Introduction::try_from(bundle.as_slice()).unwrap();
let (raya_convo_id, payloads) = raya.create_private_convo(&intro, b"hello").unwrap();
let payload = payloads.first().unwrap();
let content = saro.handle_payload(&payload.data).unwrap().unwrap();
let saro_convo_id = content.conversation_id;
let payloads = saro.send_content(&saro_convo_id, b"reply 1").unwrap();
let payload = payloads.first().unwrap();
raya.handle_payload(&payload.data).unwrap().unwrap();
let payloads = raya.send_content(&raya_convo_id, b"reply 2").unwrap();
let payload = payloads.first().unwrap();
saro.handle_payload(&payload.data).unwrap().unwrap();
// Verify conversation list
let convo_ids = saro.list_conversations().unwrap();
assert_eq!(convo_ids.len(), 1);
// Continue exchanging messages
let payloads = raya.send_content(&raya_convo_id, b"more messages").unwrap();
let payload = payloads.first().unwrap();
let content = saro
.handle_payload(&payload.data)
.expect("should decrypt")
.expect("should have content");
assert_eq!(content.data, b"more messages");
// saro can also send back
let payloads = saro.send_content(&saro_convo_id, b"saro reply").unwrap();
let payload = payloads.first().unwrap();
let content = raya
.handle_payload(&payload.data)
.unwrap()
.expect("raya should receive");
assert_eq!(content.data, b"saro reply");
}

View File

@ -5,11 +5,15 @@ edition = "2024"
description = "SQLite storage implementation for libchat" description = "SQLite storage implementation for libchat"
[dependencies] [dependencies]
crypto = { path = "../crypto" } # Workspace dependencies (sorted)
crypto = { workspace = true }
storage = { workspace = true }
# External dependencies (sorted)
hex = "0.4.3" hex = "0.4.3"
storage = { path = "../storage" }
zeroize = { version = "1.8.2", features = ["derive"] }
rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] } rusqlite = { version = "0.35", features = ["bundled-sqlcipher-vendored-openssl"] }
zeroize = { version = "1.8.2", features = ["derive"] }
[dev-dependencies] [dev-dependencies]
# External dependencies (sorted)
tempfile = "3" tempfile = "3"

View File

@ -5,5 +5,8 @@ edition = "2024"
description = "Shared storage layer for libchat" description = "Shared storage layer for libchat"
[dependencies] [dependencies]
crypto = { path = "../crypto" } # Workspace dependencies (sorted)
crypto = { workspace = true }
# External dependencies (sorted)
thiserror = "2" thiserror = "2"

View File

@ -27,6 +27,7 @@ pub trait EphemeralKeyStore {
pub enum ConversationKind { pub enum ConversationKind {
PrivateV1, PrivateV1,
Unknown(String), Unknown(String),
GroupV1,
} }
impl ConversationKind { impl ConversationKind {
@ -34,6 +35,7 @@ impl ConversationKind {
match self { match self {
Self::PrivateV1 => "private_v1", Self::PrivateV1 => "private_v1",
Self::Unknown(value) => value.as_str(), Self::Unknown(value) => value.as_str(),
Self::GroupV1 => "group_v1",
} }
} }
} }
@ -42,6 +44,7 @@ impl From<&str> for ConversationKind {
fn from(value: &str) -> Self { fn from(value: &str) -> Self {
match value { match value {
"private_v1" => Self::PrivateV1, "private_v1" => Self::PrivateV1,
"group_v1" => Self::GroupV1,
other => Self::Unknown(other.to_string()), other => Self::Unknown(other.to_string()),
} }
} }
@ -120,6 +123,8 @@ pub trait RatchetStore {
fn cleanup_old_skipped_keys(&mut self, max_age_secs: i64) -> Result<usize, StorageError>; fn cleanup_old_skipped_keys(&mut self, max_age_secs: i64) -> Result<usize, StorageError>;
} }
// TODO: (P2) this should be defined in the ConversationType
pub trait ChatStore: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore {} pub trait ChatStore: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore {}
impl<T> ChatStore for T where T: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore impl<T> ChatStore for T where T: IdentityStore + EphemeralKeyStore + ConversationStore + RatchetStore

View File

@ -11,9 +11,12 @@ name = "generate-headers"
required-features = ["headers"] required-features = ["headers"]
[dependencies] [dependencies]
safer-ffi = "0.1.13" # Workspace dependencies (sorted)
client = { path = "../client" }
libchat = { workspace = true } libchat = { workspace = true }
# External dependencies (sorted)
client = { path = "../client" }
safer-ffi = "0.1.13"
[features] [features]
headers = ["safer-ffi/headers"] headers = ["safer-ffi/headers"]

View File

@ -14,6 +14,8 @@ pub type DeliverFn = Option<
) -> i32, ) -> i32,
>; >;
#[derive(Debug)]
pub struct CDelivery { pub struct CDelivery {
pub callback: DeliverFn, pub callback: DeliverFn,
} }
@ -28,4 +30,9 @@ impl DeliveryService for CDelivery {
let rc = unsafe { cb(addr.as_ptr(), addr.len(), data.as_ptr(), data.len()) }; let rc = unsafe { cb(addr.as_ptr(), addr.len(), data.as_ptr(), data.len()) };
if rc < 0 { Err(rc) } else { Ok(()) } if rc < 0 { Err(rc) } else { Ok(()) }
} }
fn subscribe(&mut self, _delivery_address: &str) -> Result<(), Self::Error> {
// TODO: (P1) CDelivery does not support delivery_address filtering
Ok(())
}
} }

View File

@ -7,9 +7,15 @@ edition = "2024"
crate-type = ["rlib"] crate-type = ["rlib"]
[dependencies] [dependencies]
# Workspace dependencies (sorted)
chat-sqlite = { workspace = true }
components = { workspace = true }
libchat = { workspace = true } libchat = { workspace = true }
chat-sqlite = { path = "../../core/sqlite" } logos-account = { workspace = true, features = ["dev"] }
# External dependencies (sorted)
thiserror = "2" thiserror = "2"
[dev-dependencies] [dev-dependencies]
# External dependencies (sorted)
tempfile = "3" tempfile = "3"

View File

@ -1,22 +1,31 @@
use libchat::{ use libchat::{
AddressedEnvelope, ChatError, ChatStorage, ContentData, Context, ConversationIdOwned, AddressedEnvelope, ChatError, ChatStorage, ContentData, Context, ConversationIdOwned,
Introduction, StorageConfig, DeliveryService, IdentityProvider, Introduction, StorageConfig,
}; };
use logos_account::TestLogosAccount;
use crate::{delivery::DeliveryService, errors::ClientError}; use components::EphemeralRegistry;
pub struct ChatClient<D: DeliveryService> { use crate::errors::ClientError;
ctx: Context<ChatStorage>,
delivery: D, pub struct ChatClient<D>
where
D: DeliveryService + 'static,
{
ctx: Context<TestLogosAccount, D, EphemeralRegistry, ChatStorage>,
} }
impl<D: DeliveryService> ChatClient<D> { impl<D> ChatClient<D>
where
D: DeliveryService + 'static,
{
/// Create an in-memory, ephemeral client. Identity is lost on drop. /// Create an in-memory, ephemeral client. Identity is lost on drop.
pub fn new(name: impl Into<String>, delivery: D) -> Self { pub fn new(name: impl Into<String> + Clone, delivery: D) -> Self {
let account = TestLogosAccount::new(name.clone());
let registry = EphemeralRegistry::new();
let store = ChatStorage::in_memory(); let store = ChatStorage::in_memory();
Self { Self {
ctx: Context::new_with_name(name, store), ctx: Context::new_with_name(name, account, delivery, registry, store).unwrap(),
delivery,
} }
} }
@ -25,13 +34,20 @@ impl<D: DeliveryService> ChatClient<D> {
/// If an identity already exists in storage it is loaded; otherwise a new /// If an identity already exists in storage it is loaded; otherwise a new
/// one is created and saved. /// one is created and saved.
pub fn open( pub fn open(
name: impl Into<String>, identity: TestLogosAccount,
config: StorageConfig, config: StorageConfig,
delivery: D, delivery: D,
) -> Result<Self, ClientError<D::Error>> { ) -> Result<Self, ClientError<D::Error>> {
let store = ChatStorage::new(config).map_err(ChatError::from)?; let store = ChatStorage::new(config).map_err(ChatError::from)?;
let ctx = Context::new_from_store(name, store)?; let registry = EphemeralRegistry::new();
Ok(Self { ctx, delivery }) let ctx = Context::new_from_store(
identity.account_id().to_string(),
identity,
delivery,
registry,
store,
)?;
Ok(Self { ctx })
} }
/// Returns the installation name (identity label) of this client. /// Returns the installation name (identity label) of this client.
@ -86,7 +102,8 @@ impl<D: DeliveryService> ChatClient<D> {
envelopes: Vec<AddressedEnvelope>, envelopes: Vec<AddressedEnvelope>,
) -> Result<(), ClientError<D::Error>> { ) -> Result<(), ClientError<D::Error>> {
for env in envelopes { for env in envelopes {
self.delivery.publish(env).map_err(ClientError::Delivery)?; let mut delivery = self.ctx.ds();
delivery.publish(env).map_err(ClientError::Delivery)?;
} }
Ok(()) Ok(())
} }

View File

@ -1,6 +0,0 @@
use libchat::AddressedEnvelope;
pub trait DeliveryService {
type Error: std::fmt::Debug;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error>;
}

View File

@ -1,4 +1,4 @@
use crate::{AddressedEnvelope, delivery::DeliveryService}; use crate::{AddressedEnvelope, DeliveryService};
use std::collections::HashMap; use std::collections::HashMap;
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@ -10,7 +10,7 @@ type Message = Vec<u8>;
/// Messages are stored in an append-only log per delivery address. Readers hold /// Messages are stored in an append-only log per delivery address. Readers hold
/// independent [`Cursor`]s and advance their position without consuming messages, /// independent [`Cursor`]s and advance their position without consuming messages,
/// so multiple consumers on the same address each see every message. /// so multiple consumers on the same address each see every message.
#[derive(Clone, Default)] #[derive(Clone, Default, Debug)]
pub struct MessageBus { pub struct MessageBus {
log: Arc<RwLock<HashMap<String, Vec<Message>>>>, log: Arc<RwLock<HashMap<String, Vec<Message>>>>,
} }
@ -80,7 +80,7 @@ impl Iterator for Cursor {
/// clients can share one logical delivery service. Construct with a /// clients can share one logical delivery service. Construct with a
/// [`MessageBus`] and use [`cursor`](InProcessDelivery::cursor) / /// [`MessageBus`] and use [`cursor`](InProcessDelivery::cursor) /
/// [`cursor_at_tail`](InProcessDelivery::cursor_at_tail) to read messages. /// [`cursor_at_tail`](InProcessDelivery::cursor_at_tail) to read messages.
#[derive(Clone, Default)] #[derive(Clone, Default, Debug)]
pub struct InProcessDelivery(MessageBus); pub struct InProcessDelivery(MessageBus);
impl InProcessDelivery { impl InProcessDelivery {
@ -108,4 +108,9 @@ impl DeliveryService for InProcessDelivery {
self.0.push(envelope.delivery_address, envelope.data); self.0.push(envelope.delivery_address, envelope.data);
Ok(()) Ok(())
} }
fn subscribe(&mut self, _delivery_address: &str) -> Result<(), Self::Error> {
// TODO: (P1) implement subscribe
Ok(())
}
} }

View File

@ -1,7 +1,7 @@
use libchat::ChatError; use libchat::ChatError;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ClientError<D: std::fmt::Debug> { pub enum ClientError<D: std::fmt::Display> {
#[error(transparent)] #[error(transparent)]
Chat(#[from] ChatError), Chat(#[from] ChatError),
/// Crypto state advanced but at least one envelope failed delivery. /// Crypto state advanced but at least one envelope failed delivery.

View File

@ -1,12 +1,12 @@
mod client; mod client;
mod delivery;
mod delivery_in_process; mod delivery_in_process;
mod errors; mod errors;
pub use client::ChatClient; pub use client::ChatClient;
pub use delivery::DeliveryService;
pub use delivery_in_process::{Cursor, InProcessDelivery, MessageBus}; pub use delivery_in_process::{Cursor, InProcessDelivery, MessageBus};
pub use errors::ClientError; pub use errors::ClientError;
// Re-export types callers need to interact with ChatClient // Re-export types callers need to interact with ChatClient
pub use libchat::{AddressedEnvelope, ContentData, ConversationIdOwned, StorageConfig}; pub use libchat::{
AddressedEnvelope, ContentData, ConversationIdOwned, DeliveryService, StorageConfig,
};

View File

@ -1,6 +1,7 @@
use client::{ use client::{
ChatClient, ContentData, ConversationIdOwned, Cursor, InProcessDelivery, StorageConfig, ChatClient, ContentData, ConversationIdOwned, Cursor, InProcessDelivery, StorageConfig,
}; };
use logos_account::TestLogosAccount;
use std::sync::Arc; use std::sync::Arc;
fn receive(receiver: &mut ChatClient<InProcessDelivery>, cursor: &mut Cursor) -> ContentData { fn receive(receiver: &mut ChatClient<InProcessDelivery>, cursor: &mut Cursor) -> ContentData {
@ -57,11 +58,13 @@ fn open_persistent_client() {
let db_path = dir.path().join("test.db").to_string_lossy().to_string(); let db_path = dir.path().join("test.db").to_string_lossy().to_string();
let config = StorageConfig::File(db_path); let config = StorageConfig::File(db_path);
let client1 = ChatClient::open("saro", config.clone(), InProcessDelivery::default()).unwrap(); let ident1 = TestLogosAccount::new("saro");
let client1 = ChatClient::open(ident1, config.clone(), InProcessDelivery::default()).unwrap();
let name1 = client1.installation_name().to_string(); let name1 = client1.installation_name().to_string();
drop(client1); drop(client1);
let client2 = ChatClient::open("saro", config, InProcessDelivery::default()).unwrap(); let ident2 = TestLogosAccount::new("saro");
let client2 = ChatClient::open(ident2, config, InProcessDelivery::default()).unwrap();
let name2 = client2.installation_name().to_string(); let name2 = client2.installation_name().to_string();
assert_eq!( assert_eq!(

View File

@ -0,0 +1,13 @@
[package]
name = "components"
version = "0.1.0"
edition = "2024"
[dependencies]
# Workspace dependencies (sorted)
crypto = { workspace = true } # Needed because Storage traits require "Identity" struct
libchat = { workspace = true }
storage = { workspace = true }
# External dependencies (sorted)
hex = "0.4.3"

View File

@ -0,0 +1,76 @@
use std::{
collections::HashMap,
fmt::Debug,
sync::{Arc, Mutex},
};
use libchat::{AccountId, RegistrationService};
/// A Contact Registry used for Tests.
/// This implementation stores bundle bytes and then returns them when
/// retrieved
///
#[derive(Clone)]
pub struct EphemeralRegistry {
registry: Arc<Mutex<HashMap<String, Vec<u8>>>>,
}
impl EphemeralRegistry {
pub fn new() -> Self {
Self {
registry: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl Default for EphemeralRegistry {
fn default() -> Self {
Self::new()
}
}
impl Debug for EphemeralRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let registry = self.registry.lock().unwrap();
let truncated: Vec<(&String, String)> = registry
.iter()
.map(|(k, v)| {
let hex = if v.len() <= 8 {
hex::encode(v)
} else {
format!(
"{}..{}",
hex::encode(&v[..4]),
hex::encode(&v[v.len() - 4..])
)
};
(k, hex)
})
.collect();
f.debug_struct("EphemeralRegistry")
.field("registry", &truncated)
.finish()
}
}
impl RegistrationService for EphemeralRegistry {
type Error = String;
fn register(&mut self, identity: &str, key_bundle: Vec<u8>) -> Result<(), Self::Error> {
self.registry
.lock()
.unwrap()
.insert(identity.to_string(), key_bundle);
Ok(())
}
fn retrieve(&self, identity: &AccountId) -> Result<Option<Vec<u8>>, Self::Error> {
Ok(self
.registry
.lock()
.unwrap()
.get(identity.as_str())
.cloned())
}
}

View File

@ -0,0 +1,3 @@
mod local_broadcaster;
pub use local_broadcaster::LocalBroadcaster;

View File

@ -0,0 +1,126 @@
use std::{
cell::RefCell,
collections::{HashSet, VecDeque},
hash::{DefaultHasher, Hash, Hasher},
rc::Rc,
};
use libchat::{AddressedEnvelope, DeliveryService};
#[derive(Debug)]
struct BroadcasterShared<T> {
/// Per-address message queue; all published messages are appended here.
messages: VecDeque<T>,
base_index: usize,
}
impl<T> BroadcasterShared<T> {
pub fn read(&self, cursor: usize) -> Option<&T> {
self.messages.get(cursor + self.base_index)
}
pub fn tail(&self) -> usize {
self.messages.len() + self.base_index
}
}
#[derive(Clone, Debug)]
pub struct LocalBroadcaster {
shared: Rc<RefCell<BroadcasterShared<AddressedEnvelope>>>,
cursor: usize,
subscriptions: HashSet<String>,
outbound_msgs: Vec<u64>,
}
/// This is Lightweight DeliveryService which can be used for tests
/// and local examples. Messages are not delivered until `poll` is called
/// which allows for more fine grain test cases.
impl LocalBroadcaster {
pub fn new() -> Self {
let shared = Rc::new(RefCell::new(BroadcasterShared {
messages: VecDeque::new(),
base_index: 0,
}));
let cursor = shared.borrow().tail();
Self {
shared,
cursor,
subscriptions: HashSet::new(),
outbound_msgs: Vec::new(),
}
}
/// Returns a new consumer that shares the same message store but has its
/// own independent cursor — it starts from the beginning of each address
/// queue regardless of what any other consumer has already processed.
pub fn new_consumer(&self) -> Self {
let inner = self.shared.clone();
let cursor = inner.borrow().tail();
Self {
shared: inner,
cursor,
subscriptions: HashSet::new(),
outbound_msgs: Vec::new(),
}
}
/// Pulls all messages this consumer has not yet seen on `address`,
/// applying any registered filter. Advances the cursor so the same
/// messages are not returned again.
pub fn poll(&mut self) -> Option<Vec<u8>> {
loop {
let next = self.cursor;
match self.shared.borrow().read(next) {
None => return None,
Some(ae) => {
self.cursor = next + 1;
if self.subscriptions.contains(ae.delivery_address.as_str())
&& self.is_inbound(ae)
{
return Some(ae.data.clone());
}
}
}
}
}
pub fn poll_all(&mut self) -> Vec<Vec<u8>> {
std::iter::from_fn(|| self.poll()).collect()
}
fn msg_id(msg: &AddressedEnvelope) -> u64 {
let mut hasher = DefaultHasher::new();
msg.data.as_slice().hash(&mut hasher);
hasher.finish()
}
fn is_inbound(&self, msg: &AddressedEnvelope) -> bool {
let mid = Self::msg_id(msg);
!self.outbound_msgs.contains(&mid)
}
}
impl Default for LocalBroadcaster {
fn default() -> Self {
Self::new()
}
}
impl DeliveryService for LocalBroadcaster {
type Error = String;
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), Self::Error> {
self.outbound_msgs.push(Self::msg_id(&envelope));
self.shared.borrow_mut().messages.push_back(envelope);
Ok(())
}
fn subscribe(&mut self, delivery_address: &str) -> Result<(), Self::Error> {
// Strict temporal ordering of subscriptions is not enforced.
// Subscriptions are evaluated on polling, not when the message is published
self.subscriptions.insert(delivery_address.to_string());
Ok(())
}
}

View File

@ -0,0 +1,7 @@
mod contact_registry;
mod delivery;
mod storage;
pub use contact_registry::EphemeralRegistry;
pub use delivery::*;
pub use storage::*;

View File

@ -0,0 +1,3 @@
mod in_memory_store;
pub use in_memory_store::MemStore;

View File

@ -0,0 +1,136 @@
use std::collections::HashMap;
use storage::{
// TODO: (P4) Importable crates need to be prefixed with a project name to avoid conflicts
ConversationMeta,
ConversationStore,
EphemeralKeyStore,
IdentityStore,
RatchetStore,
};
/// An Test focused StorageService which holds data in a hashmap
pub struct MemStore {
convos: HashMap<String, ConversationMeta>,
}
impl MemStore {
pub fn new() -> Self {
Self {
convos: HashMap::new(),
}
}
}
impl Default for MemStore {
fn default() -> Self {
Self::new()
}
}
impl ConversationStore for MemStore {
fn save_conversation(
&mut self,
meta: &storage::ConversationMeta,
) -> Result<(), storage::StorageError> {
self.convos
.insert(meta.local_convo_id.clone(), meta.clone());
Ok(())
}
fn load_conversation(
&self,
local_convo_id: &str,
) -> Result<Option<storage::ConversationMeta>, storage::StorageError> {
let a = self.convos.get(local_convo_id).cloned();
Ok(a)
}
fn remove_conversation(&mut self, _local_convo_id: &str) -> Result<(), storage::StorageError> {
todo!()
}
fn load_conversations(&self) -> Result<Vec<storage::ConversationMeta>, storage::StorageError> {
Ok(self.convos.values().cloned().collect())
}
fn has_conversation(&self, local_convo_id: &str) -> Result<bool, storage::StorageError> {
Ok(self.convos.contains_key(local_convo_id))
}
}
impl IdentityStore for MemStore {
fn load_identity(&self) -> Result<Option<crypto::Identity>, storage::StorageError> {
// todo!()
Ok(None)
}
fn save_identity(&mut self, _identity: &crypto::Identity) -> Result<(), storage::StorageError> {
// todo!()
Ok(())
}
}
impl EphemeralKeyStore for MemStore {
fn save_ephemeral_key(
&mut self,
_public_key_hex: &str,
_private_key: &crypto::PrivateKey,
) -> Result<(), storage::StorageError> {
todo!()
}
fn load_ephemeral_key(
&self,
_public_key_hex: &str,
) -> Result<Option<crypto::PrivateKey>, storage::StorageError> {
todo!()
}
fn remove_ephemeral_key(&mut self, _public_key_hex: &str) -> Result<(), storage::StorageError> {
todo!()
}
}
impl RatchetStore for MemStore {
fn save_ratchet_state(
&mut self,
_conversation_id: &str,
_state: &storage::RatchetStateRecord,
_skipped_keys: &[storage::SkippedKeyRecord],
) -> Result<(), storage::StorageError> {
todo!()
}
fn load_ratchet_state(
&self,
_conversation_id: &str,
) -> Result<storage::RatchetStateRecord, storage::StorageError> {
todo!()
}
fn load_skipped_keys(
&self,
_conversation_id: &str,
) -> Result<Vec<storage::SkippedKeyRecord>, storage::StorageError> {
todo!()
}
fn has_ratchet_state(&self, _conversation_id: &str) -> Result<bool, storage::StorageError> {
todo!()
}
fn delete_ratchet_state(
&mut self,
_conversation_id: &str,
) -> Result<(), storage::StorageError> {
todo!()
}
fn cleanup_old_skipped_keys(
&mut self,
_max_age_secs: i64,
) -> Result<usize, storage::StorageError> {
todo!()
}
}