osmaczko 7332c152c0
feat: durable MLS storage folded into a single ChatStore
Implements issue #112: MLS group state must survive process restarts. The MLS
provider hardcoded an in-memory store separate from the chat store, so a restart
lost every group. Fold the OpenMLS StorageProvider into libchat's own ChatStore
so one durable store holds both chat state and MLS group state.

- storage: ChatStore subsumes StorageProvider<CURRENT_VERSION>, so a ChatStore is
  the whole durable-storage contract (identity/ephemeral/conversation/ratchet
  sub-stores plus the MLS key-value surface).
- chat-sqlite: ChatStorage implements StorageProvider over an mls_kv table
  (migration 003_mls_storage), a byte-faithful port of MemoryStorage with decode
  errors surfaced instead of unwrapped and the reference clear_proposal_queue
  orphan-key bug fixed. The separate SqliteMlsStorage type is removed.
- conversations: MlsPqProvider becomes a transient view borrowing the shared
  store and a long-lived crypto backend, so the store stays singly owned;
  ServiceContext hands it out via mls_provider(). ExternalServices drops its ST
  associated type, leaving one CS. GroupV2/de_mls's thread-safe-error bound is
  restated on CS because an associated-type bound does not elaborate through a
  supertrait.
- client: the store is always ChatStorage, so fix it as such and drop the
  vestigial store generic from ChatClient and the builder. Retire the stubbed
  in-memory MemStore in favour of ChatStorage::in_memory().
- GroupV1 conversations resume across a restart through the existing
  MlsGroup::load path, proven by a drop-and-reload integration test.
2026-07-01 19:55:02 +02:00

299 lines
10 KiB
Rust

mod identity;
mod mls_provider;
use chat_proto::logoschat::envelope::EnvelopeV1;
use crypto::Ed25519VerifyingKey;
use de_mls::protos::de_mls::messages::v1::MemberWelcome;
use openmls::prelude::tls_codec::Serialize;
use openmls::prelude::*;
use prost::{Message, Oneof};
use storage::{ConversationKind, ConversationMeta, ConversationStore};
use tracing::info;
use tracing::instrument;
pub use identity::MlsIdentityProvider;
pub(crate) use mls_provider::MlsPqProvider;
use crate::ChatError;
use crate::DeliveryService;
use crate::RegistrationService;
use crate::conversation::GroupConvo;
use crate::conversation::GroupV1Convo;
use crate::conversation::GroupV2Convo;
use crate::conversation::Identified as _;
use crate::service_context::{ExternalServices, ServiceContext};
use crate::utils::{blake2b_hex, hash_size};
use crate::{
AccountAuthority, AccountDirectory, AddressedEnvelope, SignedDeviceBundle,
encode_bundle_payload,
};
use crate::{IdentId, IdentIdRef, IdentityProvider};
// Downgraded from MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519 until demls accepts an external provider
pub(crate) const CIPHER_SUITE: Ciphersuite =
Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
// Define unique Identifiers derivations used in InboxV2
fn delivery_address_for(ident_id: IdentIdRef) -> String {
blake2b_hex::<hash_size::DeliveryAddr>(&["InboxV2|", "delivery_address|", ident_id.as_str()])
}
fn conversation_id_for(ident_id: IdentIdRef) -> String {
blake2b_hex::<hash_size::ConvoId>(&["InboxV2|", "conversation_id|", ident_id.as_str()])
}
/// 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,
ident_id: IdentIdRef,
welcome: &MlsMessageOut,
) -> Result<(), ChatError>;
}
/// Deliver a de-mls welcome to `account_id` over its InboxV2 1-1 channel.
/// Function mirroring the GroupV1 `invite_user` path, but carrying a de-mls `MemberWelcome`.
pub fn invite_user_v2<DS: DeliveryService>(
ds: &mut DS,
account_id: IdentIdRef,
welcome: &MemberWelcome,
) -> Result<(), ChatError> {
let frame = InboxV2Frame {
payload: Some(InviteType::GroupV2(welcome.encode_to_vec())),
};
let envelope = EnvelopeV1 {
conversation_hint: conversation_id_for(account_id),
salt: 0,
payload: frame.encode_to_vec().into(),
};
ds.publish(AddressedEnvelope {
delivery_address: delivery_address_for(account_id),
data: envelope.encode_to_vec(),
})
.map_err(ChatError::generic)
}
/// An PQ focused Conversation initializer.
/// InboxV2 Incorporates an Account based identity system to support PQ based conversation protocols
/// such as MLS.
pub struct InboxV2 {
// Account_id field is an owned value, so it can be returned via reference.
ident_id: IdentId,
}
impl InboxV2 {
pub fn new(ident_id: IdentId) -> Self {
Self { ident_id }
}
pub fn ident_id(&self) -> IdentIdRef<'_> {
&self.ident_id
}
/// Submit MlsKeypackage to registration service
pub fn register<S: ExternalServices>(
&mut self,
cx: &mut ServiceContext<S>,
) -> Result<(), ChatError> {
let keypackage_bytes = Self::create_keypackage(cx)?.tls_serialize_detached()?;
// TODO: (P3) Each keypackage can only be used once either enable...
// "LastResort" package or publish multiple
cx.registry
.register(&cx.mls_identity, keypackage_bytes)
.map_err(ChatError::generic)?;
Ok(())
}
pub fn delivery_address(&self) -> String {
delivery_address_for(&self.ident_id)
}
pub fn id(&self) -> String {
conversation_id_for(&self.ident_id)
}
#[instrument(name = "inboxV2.handle_frame", skip_all, fields(user_id = %service_ctx.mls_identity.display_name()))]
pub fn handle_frame<S: ExternalServices>(
&self,
service_ctx: &mut ServiceContext<S>,
payload_bytes: &[u8],
) -> Result<Option<Box<dyn GroupConvo<S>>>, ChatError> {
// On a broadcast transport the inbox address also receives traffic
// that isn't an invite (or that prost decodes into an empty frame).
// Treat anything we can't interpret as "not for us" and skip it,
// rather than failing the whole poll cycle.
let Ok(inbox_frame) = InboxV2Frame::decode(payload_bytes) else {
return Ok(None);
};
let Some(payload) = inbox_frame.payload else {
return Ok(None);
};
match payload {
InviteType::GroupV1(inv) => {
Ok(Some(Box::new(self.handle_heavy_invite(service_ctx, inv)?)))
}
InviteType::GroupV2(welcome_bytes) => {
info!("Process WelcomeMessage");
let mw =
MemberWelcome::decode(welcome_bytes.as_slice()).map_err(ChatError::generic)?;
let convo = GroupV2Convo::new_from_welcome(service_ctx, &mw)?;
Ok(Some(Box::new(convo)))
}
}
}
fn persist_convo<S: ExternalServices>(
&self,
convo: &GroupV1Convo,
cx: &mut ServiceContext<S>,
) -> 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: ConversationKind::GroupV1,
};
cx.store.save_conversation(&meta)?;
// TODO: (P1) Persist state
Ok(())
}
fn handle_heavy_invite<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
invite: GroupV1HeavyInvite,
) -> Result<GroupV1Convo, 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(cx, welcome)?;
self.persist_convo(&convo, cx)?;
Ok(convo)
}
fn create_keypackage<S: ExternalServices>(
cx: &ServiceContext<S>,
) -> Result<KeyPackage, ChatError> {
let capabilities = Capabilities::builder()
.ciphersuites(vec![CIPHER_SUITE])
.extensions(vec![ExtensionType::ApplicationId])
.build();
let a = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.build(
CIPHER_SUITE,
&cx.mls_provider(),
&cx.mls_identity,
cx.mls_identity.get_credential(),
)
.expect("Failed to build KeyPackage");
Ok(a.key_package().clone())
}
}
// Publishing the account → device bundle needs the account key, so this method
// is available only when the registry also implements `AccountDirectory`. The
// signing authority is the `LogosAccount` wrapped by `mls_identity`; on testnet
// that is a local key (account key == device key), while an external signer
// would supply its own authority.
impl InboxV2 {
/// Add this installation's device key to the account's directory bundle.
///
/// Fetches the current (verified) device set, adds this device if absent,
/// bumps the lamport, re-signs with the account key, and publishes. Safe to
/// call repeatedly — an unchanged set is simply re-published, which also
/// refreshes the server's retention clock.
pub fn publish_device_bundle<S: ExternalServices>(
&self,
cx: &mut ServiceContext<S>,
) -> Result<(), ChatError> {
// On testnet `mls_identity` doubles as the `AccountAuthority` — the
// account key is the installation's own key.
let authority = &cx.mls_identity;
let account_pub = AccountAuthority::account_pub(authority).clone();
let device_key = cx.mls_identity.public_key().clone();
let device_hex = hex::encode(device_key.as_ref());
// Start from the devices already registered so other installations of
// this account are preserved across the upsert.
let existing = cx
.registry
.fetch(&account_pub)
.map_err(|e| ChatError::Generic(e.to_string()))?;
let (mut devices, next_lamport) = match existing {
Some(set) => {
let mut keys = Vec::with_capacity(set.devices.len() + 1);
for hex_id in &set.devices {
let bytes: [u8; 32] = hex::decode(hex_id)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| {
ChatError::Generic("directory returned a malformed device id".into())
})?;
let key = Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| {
ChatError::Generic("directory returned a malformed device key".into())
})?;
keys.push(key);
}
(keys, set.lamport + 1)
}
None => (Vec::new(), 0),
};
if !devices
.iter()
.any(|d| hex::encode(d.as_ref()) == device_hex)
{
devices.push(device_key);
}
let payload = encode_bundle_payload(next_lamport, &devices);
let signature = AccountAuthority::sign(authority, &payload)
.map_err(|e| ChatError::Generic(e.to_string()))?;
let bundle = SignedDeviceBundle {
account_pub,
payload,
signature,
};
cx.registry
.publish(&bundle)
.map_err(|e| ChatError::Generic(e.to_string()))
}
}
#[derive(Clone, PartialEq, Message)]
pub struct InboxV2Frame {
#[prost(oneof = "InviteType", tags = "1, 2")]
pub payload: Option<InviteType>,
}
#[derive(Clone, PartialEq, Oneof)]
pub enum InviteType {
#[prost(message, tag = "1")]
GroupV1(GroupV1HeavyInvite),
#[prost(bytes, tag = "2")]
GroupV2(Vec<u8>),
}
#[derive(Clone, PartialEq, Message)]
pub struct GroupV1HeavyInvite {
#[prost(bytes, tag = "1")]
pub welcome_bytes: Vec<u8>,
}