chore: rename quicproquo → quicprochat in Rust workspace

Rename all crate directories, package names, binary names, proto
package/module paths, ALPN strings, env var prefixes, config filenames,
mDNS service names, and plugin ABI symbols from quicproquo/qpq to
quicprochat/qpc.
This commit is contained in:
2026-03-07 18:24:52 +01:00
parent d8c1392587
commit a710037dde
212 changed files with 609 additions and 609 deletions

View File

@@ -0,0 +1,142 @@
//! OPAQUE authentication — register and login via the v2 RPC protocol.
//!
//! Wraps the `opaque-ke` crate to perform the OPAQUE 3-message flow against
//! the quicprochat server using prost-encoded protobuf messages over `RpcClient::call`.
use bytes::Bytes;
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
};
use prost::Message;
use quicprochat_core::{opaque_auth::OpaqueSuite, IdentityKeypair};
use quicprochat_proto::{method_ids, qpc::v1};
use quicprochat_rpc::client::RpcClient;
use crate::error::SdkError;
/// Register a new user account via the OPAQUE protocol.
///
/// If `identity` is `None`, a fresh Ed25519 keypair is generated.
/// Returns the identity keypair bound to this account.
pub async fn opaque_register(
rpc: &RpcClient,
username: &str,
password: &str,
identity: Option<&IdentityKeypair>,
) -> Result<IdentityKeypair, SdkError> {
let mut rng = rand::rngs::OsRng;
// Generate or use provided identity.
let keypair = match identity {
Some(kp) => IdentityKeypair::from_seed(*kp.seed_bytes()),
None => IdentityKeypair::generate(),
};
// ── Step 1: Registration Start ─────────────────────────────────────────
let reg_start = ClientRegistration::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| SdkError::AuthFailed(format!("OPAQUE register start: {e}")))?;
let start_req = v1::OpaqueRegisterStartRequest {
username: username.to_string(),
request: reg_start.message.serialize().to_vec(),
};
let resp_bytes = rpc
.call(method_ids::OPAQUE_REGISTER_START, Bytes::from(start_req.encode_to_vec()))
.await?;
let start_resp = v1::OpaqueRegisterStartResponse::decode(resp_bytes)
.map_err(|e| SdkError::AuthFailed(format!("decode register_start response: {e}")))?;
let reg_response = RegistrationResponse::<OpaqueSuite>::deserialize(&start_resp.response)
.map_err(|e| SdkError::AuthFailed(format!("invalid registration response: {e}")))?;
// ── Step 2: Registration Finish ────────────────────────────────────────
let reg_finish = reg_start
.state
.finish(
&mut rng,
password.as_bytes(),
reg_response,
ClientRegistrationFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| SdkError::AuthFailed(format!("OPAQUE register finish: {e}")))?;
let finish_req = v1::OpaqueRegisterFinishRequest {
username: username.to_string(),
upload: reg_finish.message.serialize().to_vec(),
identity_key: keypair.public_key_bytes().to_vec(),
};
let resp_bytes = rpc
.call(method_ids::OPAQUE_REGISTER_FINISH, Bytes::from(finish_req.encode_to_vec()))
.await?;
let finish_resp = v1::OpaqueRegisterFinishResponse::decode(resp_bytes)
.map_err(|e| SdkError::AuthFailed(format!("decode register_finish response: {e}")))?;
if !finish_resp.success {
return Err(SdkError::AuthFailed("server rejected registration".into()));
}
Ok(keypair)
}
/// Log in via the OPAQUE protocol and receive a session token.
///
/// `identity_key` is the 32-byte Ed25519 public key to bind to this session.
/// Returns the session token bytes.
pub async fn opaque_login(
rpc: &RpcClient,
username: &str,
password: &str,
identity_key: &[u8],
) -> Result<Vec<u8>, SdkError> {
let mut rng = rand::rngs::OsRng;
// ── Step 1: Login Start ────────────────────────────────────────────────
let login_start = ClientLogin::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| SdkError::AuthFailed(format!("OPAQUE login start: {e}")))?;
let start_req = v1::OpaqueLoginStartRequest {
username: username.to_string(),
request: login_start.message.serialize().to_vec(),
};
let resp_bytes = rpc
.call(method_ids::OPAQUE_LOGIN_START, Bytes::from(start_req.encode_to_vec()))
.await?;
let start_resp = v1::OpaqueLoginStartResponse::decode(resp_bytes)
.map_err(|e| SdkError::AuthFailed(format!("decode login_start response: {e}")))?;
let credential_response = CredentialResponse::<OpaqueSuite>::deserialize(&start_resp.response)
.map_err(|e| SdkError::AuthFailed(format!("invalid credential response: {e}")))?;
// ── Step 2: Login Finish ───────────────────────────────────────────────
let login_finish = login_start
.state
.finish(
&mut rng,
password.as_bytes(),
credential_response,
ClientLoginFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| SdkError::AuthFailed(format!("OPAQUE login finish (bad password?): {e}")))?;
let finish_req = v1::OpaqueLoginFinishRequest {
username: username.to_string(),
finalization: login_finish.message.serialize().to_vec(),
identity_key: identity_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::OPAQUE_LOGIN_FINISH, Bytes::from(finish_req.encode_to_vec()))
.await?;
let finish_resp = v1::OpaqueLoginFinishResponse::decode(resp_bytes)
.map_err(|e| SdkError::AuthFailed(format!("decode login_finish response: {e}")))?;
if finish_resp.session_token.is_empty() {
return Err(SdkError::AuthFailed("server returned empty session token".into()));
}
Ok(finish_resp.session_token)
}

View File

@@ -0,0 +1,348 @@
//! `QpqClient` — the main entry point for the quicprochat SDK.
use std::sync::Arc;
use tokio::sync::broadcast;
use tracing::info;
use crate::config::ClientConfig;
use crate::conversation::ConversationStore;
use crate::error::SdkError;
use crate::events::ClientEvent;
/// The main SDK client. All state is contained within this struct — no globals.
pub struct QpqClient {
config: ClientConfig,
rpc: Option<quicprochat_rpc::client::RpcClient>,
event_tx: broadcast::Sender<ClientEvent>,
/// The authenticated username, if logged in.
username: Option<String>,
/// The local identity key (Ed25519 public key, 32 bytes).
identity_key: Option<Vec<u8>>,
/// Session token from OPAQUE login.
session_token: Option<Vec<u8>>,
/// Local conversation store (SQLCipher).
conv_store: Option<ConversationStore>,
/// Device ID for multi-device support.
/// When set, fetch/peek/ack requests include this device_id so the server
/// scopes them to the correct per-device queue.
device_id: Option<Vec<u8>>,
}
impl QpqClient {
/// Create a new client with the given configuration.
pub fn new(config: ClientConfig) -> Self {
let (event_tx, _) = broadcast::channel(256);
Self {
config,
rpc: None,
event_tx,
username: None,
identity_key: None,
session_token: None,
conv_store: None,
device_id: None,
}
}
/// Connect to the server.
pub async fn connect(&mut self) -> Result<(), SdkError> {
let tls_config = build_tls_config(self.config.accept_invalid_certs)?;
let rpc_config = quicprochat_rpc::client::RpcClientConfig {
server_addr: self.config.server_addr,
server_name: self.config.server_name.clone(),
tls_config: Arc::new(tls_config),
alpn: self.config.alpn.clone(),
session_token: self.session_token.clone(),
};
let client = quicprochat_rpc::client::RpcClient::connect(rpc_config).await?;
self.rpc = Some(client);
// Open local conversation store.
let store = ConversationStore::open(
&self.config.db_path,
self.config.db_password.as_deref(),
)?;
self.conv_store = Some(store);
self.emit(ClientEvent::Connected);
info!(server = %self.config.server_addr, "connected");
Ok(())
}
/// Subscribe to client events. Returns a broadcast receiver.
pub fn subscribe(&self) -> broadcast::Receiver<ClientEvent> {
self.event_tx.subscribe()
}
/// Get the authenticated username, if logged in.
pub fn username(&self) -> Option<&str> {
self.username.as_deref()
}
/// Get the local identity key.
pub fn identity_key(&self) -> Option<&[u8]> {
self.identity_key.as_deref()
}
/// Whether the client is connected.
pub fn is_connected(&self) -> bool {
self.rpc.is_some()
}
/// Whether the client is authenticated.
pub fn is_authenticated(&self) -> bool {
self.session_token.is_some()
}
/// Get the server address as a string (e.g. "127.0.0.1:7000").
pub fn server_addr_string(&self) -> String {
self.config.server_addr.to_string()
}
/// Get the state file path from the client configuration.
pub fn config_state_path(&self) -> std::path::PathBuf {
self.config.state_path.clone()
}
/// Get a reference to the RPC client (for direct calls).
pub fn rpc(&self) -> Result<&quicprochat_rpc::client::RpcClient, SdkError> {
self.rpc.as_ref().ok_or(SdkError::NotConnected)
}
/// Get a reference to the conversation store.
pub fn conversations(&self) -> Result<&ConversationStore, SdkError> {
self.conv_store
.as_ref()
.ok_or(SdkError::NotConnected)
}
/// Register a new user account via OPAQUE.
///
/// Generates a fresh identity keypair, registers it with the server, and
/// stores the identity key locally.
pub async fn register(&mut self, username: &str, password: &str) -> Result<(), SdkError> {
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
let keypair = crate::auth::opaque_register(rpc, username, password, None).await?;
self.identity_key = Some(keypair.public_key_bytes().to_vec());
self.emit(ClientEvent::Registered {
username: username.to_string(),
});
info!(username, "registered");
Ok(())
}
/// Log in via OPAQUE and store the session token.
///
/// Requires an identity key to be set (either from a previous `register()`
/// call or loaded from state). After login, the client is authenticated
/// and subsequent RPC calls include the session token.
pub async fn login(&mut self, username: &str, password: &str) -> Result<(), SdkError> {
let identity_key = self
.identity_key
.as_ref()
.ok_or_else(|| SdkError::AuthFailed("no identity key — register or load state first".into()))?
.clone();
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
let session_token = crate::auth::opaque_login(rpc, username, password, &identity_key).await?;
self.session_token = Some(session_token);
self.username = Some(username.to_string());
self.emit(ClientEvent::LoggedIn {
username: username.to_string(),
});
info!(username, "logged in");
Ok(())
}
/// Clear authentication state (session token, username).
pub fn logout(&mut self) -> Result<(), SdkError> {
self.session_token = None;
let username = self.username.take();
self.emit(ClientEvent::LoggedOut {
username: username.unwrap_or_default(),
});
info!("logged out");
Ok(())
}
/// Set the identity key directly (e.g. after loading from state).
pub fn set_identity_key(&mut self, key: Vec<u8>) {
self.identity_key = Some(key);
}
/// Get the session token, if authenticated.
pub fn session_token(&self) -> Option<&[u8]> {
self.session_token.as_deref()
}
// ── Multi-device ─────────────────────────────────────────────────────────
/// Set the device ID for this client. Subsequent fetch/peek/ack calls
/// will include this ID so the server scopes them to the correct queue.
pub fn set_device_id(&mut self, device_id: Vec<u8>) {
self.device_id = Some(device_id);
}
/// Get the current device ID, if set.
pub fn device_id(&self) -> Option<&[u8]> {
self.device_id.as_deref()
}
/// Register this device with the server.
/// Sets the local device_id on success.
pub async fn register_device(
&mut self,
device_id: &[u8],
device_name: &str,
) -> Result<bool, SdkError> {
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
let newly_registered =
crate::devices::register_device(rpc, device_id, device_name).await?;
self.device_id = Some(device_id.to_vec());
Ok(newly_registered)
}
/// List all registered devices for this identity.
pub async fn list_devices(&self) -> Result<Vec<crate::devices::DeviceInfo>, SdkError> {
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
crate::devices::list_devices(rpc).await
}
/// Revoke (remove) a registered device.
pub async fn revoke_device(&self, device_id: &[u8]) -> Result<bool, SdkError> {
let rpc = self.rpc.as_ref().ok_or(SdkError::NotConnected)?;
crate::devices::revoke_device(rpc, device_id).await
}
// ── Moderation (client-side) ────────────────────────────────────────────
/// Block a user locally. Their messages will be hidden from display.
pub fn block_user(&self, identity_key: &[u8], reason: &str) -> Result<(), SdkError> {
let store = self.conversations()?;
store
.block_user(identity_key, reason)
.map_err(|e| SdkError::Storage(e.to_string()))?;
info!(identity = %hex::encode(identity_key), "user blocked");
Ok(())
}
/// Unblock a user locally.
pub fn unblock_user(&self, identity_key: &[u8]) -> Result<bool, SdkError> {
let store = self.conversations()?;
let removed = store
.unblock_user(identity_key)
.map_err(|e| SdkError::Storage(e.to_string()))?;
if removed {
info!(identity = %hex::encode(identity_key), "user unblocked");
}
Ok(removed)
}
/// Check if a user is blocked locally.
pub fn is_blocked(&self, identity_key: &[u8]) -> Result<bool, SdkError> {
let store = self.conversations()?;
store
.is_blocked(identity_key)
.map_err(|e| SdkError::Storage(e.to_string()))
}
/// List all locally blocked users.
pub fn list_blocked(&self) -> Result<Vec<crate::conversation::BlockedUser>, SdkError> {
let store = self.conversations()?;
store
.list_blocked()
.map_err(|e| SdkError::Storage(e.to_string()))
}
/// Disconnect from the server.
pub fn disconnect(&mut self) {
if let Some(rpc) = self.rpc.take() {
rpc.close();
self.emit(ClientEvent::Disconnected {
reason: "client closed".into(),
});
}
}
fn emit(&self, event: ClientEvent) {
// Ignore send errors (no subscribers).
let _ = self.event_tx.send(event);
}
}
impl Drop for QpqClient {
fn drop(&mut self) {
self.disconnect();
}
}
fn build_tls_config(accept_invalid_certs: bool) -> Result<rustls::ClientConfig, SdkError> {
let builder = rustls::ClientConfig::builder();
if accept_invalid_certs {
let config = builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(InsecureVerifier))
.with_no_client_auth();
Ok(config)
} else {
let roots = rustls::RootCertStore::empty();
let config = builder
.with_root_certificates(roots)
.with_no_client_auth();
Ok(config)
}
}
/// A TLS verifier that accepts any certificate (for dev mode only).
#[derive(Debug)]
struct InsecureVerifier;
impl rustls::client::danger::ServerCertVerifier for InsecureVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
vec![
rustls::SignatureScheme::RSA_PKCS1_SHA256,
rustls::SignatureScheme::RSA_PKCS1_SHA384,
rustls::SignatureScheme::RSA_PKCS1_SHA512,
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
rustls::SignatureScheme::ED25519,
rustls::SignatureScheme::RSA_PSS_SHA256,
rustls::SignatureScheme::RSA_PSS_SHA384,
rustls::SignatureScheme::RSA_PSS_SHA512,
]
}
}

View File

@@ -0,0 +1,47 @@
//! Client configuration.
use std::net::SocketAddr;
use std::path::PathBuf;
/// Configuration for a `QpqClient` instance.
#[derive(Debug, Clone)]
pub struct ClientConfig {
/// Server address (host:port).
pub server_addr: SocketAddr,
/// Server hostname for TLS SNI.
pub server_name: String,
/// Path to the local conversation database.
pub db_path: PathBuf,
/// Password for encrypting the local database (SQLCipher).
/// If `None`, the database is stored unencrypted.
pub db_password: Option<String>,
/// Path to the local state file (identity key, MLS state).
pub state_path: PathBuf,
/// Whether to accept self-signed TLS certificates (dev mode only).
pub accept_invalid_certs: bool,
/// ALPN protocol identifier for the RPC service.
pub alpn: Vec<u8>,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
server_addr: std::net::SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
7000,
),
server_name: "localhost".to_string(),
db_path: PathBuf::from("conversations.db"),
db_password: None,
state_path: PathBuf::from("client-state.bin"),
accept_invalid_certs: false,
alpn: b"qpc/2".to_vec(),
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
//! Device management — register, list, and revoke devices.
use quicprochat_proto::bytes::Bytes;
use quicprochat_proto::prost::Message;
use quicprochat_proto::{method_ids, qpc::v1};
use quicprochat_rpc::client::RpcClient;
use crate::error::SdkError;
/// Info about a registered device.
pub struct DeviceInfo {
pub device_id: Vec<u8>,
pub device_name: String,
pub registered_at: u64,
}
/// Register a device for multi-device support.
/// Returns `true` if the device was newly registered, `false` if it already existed.
pub async fn register_device(
rpc: &RpcClient,
device_id: &[u8],
device_name: &str,
) -> Result<bool, SdkError> {
let req = v1::RegisterDeviceRequest {
device_id: device_id.to_vec(),
device_name: device_name.to_string(),
};
let resp_bytes = rpc
.call(method_ids::REGISTER_DEVICE, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::RegisterDeviceResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode RegisterDeviceResponse: {e}")))?;
Ok(resp.success)
}
/// List all registered devices for the authenticated identity.
pub async fn list_devices(
rpc: &RpcClient,
) -> Result<Vec<DeviceInfo>, SdkError> {
let req = v1::ListDevicesRequest {};
let resp_bytes = rpc
.call(method_ids::LIST_DEVICES, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::ListDevicesResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode ListDevicesResponse: {e}")))?;
let devices = resp
.devices
.into_iter()
.map(|d| DeviceInfo {
device_id: d.device_id,
device_name: d.device_name,
registered_at: d.registered_at,
})
.collect();
Ok(devices)
}
/// Revoke (remove) a registered device.
/// Returns `true` if the device was found and revoked.
pub async fn revoke_device(
rpc: &RpcClient,
device_id: &[u8],
) -> Result<bool, SdkError> {
let req = v1::RevokeDeviceRequest {
device_id: device_id.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::REVOKE_DEVICE, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::RevokeDeviceResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode RevokeDeviceResponse: {e}")))?;
Ok(resp.success)
}

View File

@@ -0,0 +1,29 @@
//! SDK error types.
/// Errors returned by SDK operations.
#[derive(Debug, thiserror::Error)]
pub enum SdkError {
#[error("not connected to server")]
NotConnected,
#[error("not authenticated — call login() first")]
NotAuthenticated,
#[error("authentication failed: {0}")]
AuthFailed(String),
#[error("conversation not found: {0}")]
ConversationNotFound(String),
#[error("crypto error: {0}")]
Crypto(String),
#[error("RPC error: {0}")]
Rpc(#[from] quicprochat_rpc::error::RpcError),
#[error("storage error: {0}")]
Storage(String),
#[error("{0}")]
Other(#[from] anyhow::Error),
}

View File

@@ -0,0 +1,84 @@
//! Client event system — real-time notifications from the SDK.
/// Events emitted by the SDK to the UI layer.
#[derive(Debug, Clone)]
pub enum ClientEvent {
/// Successfully connected to the server.
Connected,
/// Disconnected from the server.
Disconnected { reason: String },
/// Registration succeeded.
Registered { username: String },
/// Login succeeded.
LoggedIn { username: String },
/// Logged out.
LoggedOut { username: String },
/// Authentication succeeded.
Authenticated { username: String },
/// A new message was received in a conversation.
MessageReceived {
conversation_id: [u8; 16],
sender_key: Vec<u8>,
sender_name: Option<String>,
body: String,
timestamp_ms: u64,
},
/// A message was sent successfully.
MessageSent {
conversation_id: [u8; 16],
seq: u64,
},
/// A new conversation was created or discovered.
ConversationCreated {
conversation_id: [u8; 16],
display_name: String,
},
/// A member was added to a group conversation.
MemberAdded {
conversation_id: [u8; 16],
member_key: Vec<u8>,
},
/// A member was removed from a group conversation.
MemberRemoved {
conversation_id: [u8; 16],
member_key: Vec<u8>,
},
/// Server-push event received.
PushEvent {
event_type: u16,
payload: Vec<u8>,
},
/// A message was queued in the offline outbox (send failed or disconnected).
MessageQueued {
outbox_id: i64,
conversation_id: [u8; 16],
},
/// Outbox flush completed after reconnect.
OutboxFlushed {
sent: usize,
failed: usize,
},
/// Gap detected in message sequence numbers.
MessageGap {
conversation_id: [u8; 16],
expected_seq: u64,
received_seq: u64,
},
/// An error occurred in the background.
Error { message: String },
}

View File

@@ -0,0 +1,589 @@
//! Group lifecycle — create DMs, groups, invite members, join from Welcome.
//!
//! All functions are free-standing (not methods on `QpqClient`) so they can be
//! tested and composed independently.
use std::sync::Arc;
use bytes::Bytes;
use prost::Message;
use tracing::debug;
use quicprochat_core::{
hybrid_encrypt, GroupMember, HybridKeypair, HybridPublicKey, IdentityKeypair,
};
use quicprochat_proto::method_ids;
use quicprochat_proto::qpc::v1::{
CreateChannelRequest, CreateChannelResponse, EnqueueRequest, EnqueueResponse,
ListGroupMembersRequest, ListGroupMembersResponse, RemoveMemberRequest, RemoveMemberResponse,
RotateKeysRequest, RotateKeysResponse, UpdateGroupMetadataRequest, UpdateGroupMetadataResponse,
};
use quicprochat_rpc::client::RpcClient;
use crate::conversation::{
now_ms, Conversation, ConversationId, ConversationKind, ConversationStore,
};
use crate::error::SdkError;
// ── DM (1:1) ────────────────────────────────────────────────────────────────
/// Create or join a 1:1 DM channel with a peer.
///
/// Returns `(conversation_id, was_new)`.
/// - `was_new = true` — caller created the MLS group and sent the Welcome.
/// - `was_new = false` — peer is the MLS initiator; caller should wait for Welcome.
#[allow(clippy::too_many_arguments)]
pub async fn create_dm(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
my_identity: &IdentityKeypair,
peer_key: &[u8],
peer_key_package: &[u8],
hybrid_kp: Option<&HybridKeypair>,
peer_hybrid_pk: Option<&HybridPublicKey>,
) -> Result<(ConversationId, bool), SdkError> {
// 1. Call CREATE_CHANNEL RPC.
let req = CreateChannelRequest {
peer_key: peer_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::CREATE_CHANNEL, Bytes::from(req.encode_to_vec()))
.await?;
let resp = CreateChannelResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode CreateChannelResponse: {e}")))?;
let conv_id = ConversationId::from_slice(&resp.channel_id)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("server returned invalid channel_id")))?;
let was_new = resp.was_new;
if was_new {
// 2a. We are the MLS initiator.
member
.create_group(&resp.channel_id)
.map_err(|e| SdkError::Crypto(format!("create_group: {e}")))?;
let (_commit, welcome) = member
.add_member(peer_key_package)
.map_err(|e| SdkError::Crypto(format!("add_member: {e}")))?;
// Optionally hybrid-wrap the welcome.
let payload = wrap_hybrid(hybrid_kp, peer_hybrid_pk, &welcome, member.is_hybrid())?;
// Enqueue welcome to peer.
enqueue_to_peer(rpc, peer_key, &payload).await?;
// Save conversation with MLS state.
let member_keys = member.member_identities();
let mls_blob = member
.group_ref()
.map(bincode::serialize)
.transpose()
.map_err(|e| SdkError::Storage(format!("serialize MLS group: {e}")))?;
let conv = Conversation {
id: conv_id.clone(),
kind: ConversationKind::Dm {
peer_key: peer_key.to_vec(),
peer_username: None,
},
display_name: format!("DM:{}", hex::encode(&peer_key[..4])),
mls_group_blob: mls_blob,
keystore_blob: None,
member_keys,
unread_count: 0,
last_activity_ms: now_ms(),
created_at_ms: now_ms(),
is_hybrid: member.is_hybrid(),
last_seen_seq: 0,
};
conv_store
.save_conversation(&conv)
.map_err(|e| SdkError::Storage(e.to_string()))?;
debug!(conv = %conv_id.hex(), "DM created (initiator)");
} else {
// 2b. Peer is the MLS initiator — save a stub.
let conv = Conversation {
id: conv_id.clone(),
kind: ConversationKind::Dm {
peer_key: peer_key.to_vec(),
peer_username: None,
},
display_name: format!("DM:{}", hex::encode(&peer_key[..4])),
mls_group_blob: None,
keystore_blob: None,
member_keys: vec![my_identity.public_key_bytes().to_vec(), peer_key.to_vec()],
unread_count: 0,
last_activity_ms: now_ms(),
created_at_ms: now_ms(),
is_hybrid: false,
last_seen_seq: 0,
};
conv_store
.save_conversation(&conv)
.map_err(|e| SdkError::Storage(e.to_string()))?;
debug!(conv = %conv_id.hex(), "DM stub saved (waiting for Welcome)");
}
Ok((conv_id, was_new))
}
// ── Group creation ──────────────────────────────────────────────────────────
/// Create a new group conversation (local only, no RPC).
pub fn create_group(
conv_store: &ConversationStore,
member: &mut GroupMember,
group_name: &str,
) -> Result<ConversationId, SdkError> {
let conv_id = ConversationId::from_group_name(group_name);
member
.create_group(conv_id.0.as_slice())
.map_err(|e| SdkError::Crypto(format!("create_group: {e}")))?;
let member_keys = member.member_identities();
let mls_blob = member
.group_ref()
.map(bincode::serialize)
.transpose()
.map_err(|e| SdkError::Storage(format!("serialize MLS group: {e}")))?;
let conv = Conversation {
id: conv_id.clone(),
kind: ConversationKind::Group {
name: group_name.to_string(),
},
display_name: format!("#{group_name}"),
mls_group_blob: mls_blob,
keystore_blob: None,
member_keys,
unread_count: 0,
last_activity_ms: now_ms(),
created_at_ms: now_ms(),
is_hybrid: member.is_hybrid(),
last_seen_seq: 0,
};
conv_store
.save_conversation(&conv)
.map_err(|e| SdkError::Storage(e.to_string()))?;
debug!(conv = %conv_id.hex(), group = group_name, "group created");
Ok(conv_id)
}
// ── Group invite ────────────────────────────────────────────────────────────
/// Invite a peer to an existing group.
///
/// Sends the Welcome to the new peer and the Commit to all existing members.
#[allow(clippy::too_many_arguments)]
pub async fn invite_to_group(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
my_identity: &IdentityKeypair,
conv_id: &ConversationId,
peer_key: &[u8],
peer_key_package: &[u8],
hybrid_kp: Option<&HybridKeypair>,
peer_hybrid_pk: Option<&HybridPublicKey>,
) -> Result<(), SdkError> {
let my_key = my_identity.public_key_bytes();
let (_commit, welcome) = member
.add_member(peer_key_package)
.map_err(|e| SdkError::Crypto(format!("add_member: {e}")))?;
// Send Welcome to new peer.
let payload = wrap_hybrid(hybrid_kp, peer_hybrid_pk, &welcome, member.is_hybrid())?;
enqueue_to_peer(rpc, peer_key, &payload).await?;
// Persist updated MLS state.
save_mls_state(conv_store, conv_id, member)?;
debug!(
conv = %conv_id.hex(),
peer = %hex::encode(&peer_key[..4]),
"invited peer to group"
);
let _ = my_key; // used for filtering in future commit broadcast
Ok(())
}
// ── Join from Welcome ───────────────────────────────────────────────────────
/// Join a group from a Welcome message.
///
/// Returns the conversation ID derived from the MLS group ID.
pub fn join_from_welcome(
conv_store: &ConversationStore,
member: &mut GroupMember,
welcome_bytes: &[u8],
hybrid_kp: Option<&HybridKeypair>,
) -> Result<ConversationId, SdkError> {
// Try hybrid decryption if we have a hybrid keypair.
let decrypted;
let welcome_data = if let Some(hkp) = hybrid_kp {
match quicprochat_core::hybrid_decrypt(hkp, welcome_bytes, b"", b"") {
Ok(plain) => {
decrypted = plain;
&decrypted[..]
}
Err(_) => welcome_bytes, // not hybrid-encrypted, use as-is
}
} else {
welcome_bytes
};
member
.join_group(welcome_data)
.map_err(|e| SdkError::Crypto(format!("join_group: {e}")))?;
let group_id = member
.group_id()
.ok_or_else(|| SdkError::Crypto("no group after join".into()))?;
let conv_id = ConversationId::from_slice(&group_id)
.ok_or_else(|| SdkError::Crypto("group_id is not 16 bytes".into()))?;
let member_keys = member.member_identities();
let mls_blob = member
.group_ref()
.map(bincode::serialize)
.transpose()
.map_err(|e| SdkError::Storage(format!("serialize MLS group: {e}")))?;
// Upsert conversation — the stub may already exist from create_dm.
let existing = conv_store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?;
let conv = if let Some(mut ex) = existing {
ex.mls_group_blob = mls_blob;
ex.member_keys = member_keys;
ex.is_hybrid = member.is_hybrid();
ex.last_activity_ms = now_ms();
ex
} else {
Conversation {
id: conv_id.clone(),
kind: ConversationKind::Group {
name: format!("group-{}", conv_id.hex()),
},
display_name: format!("group-{}", &conv_id.hex()[..8]),
mls_group_blob: mls_blob,
keystore_blob: None,
member_keys,
unread_count: 0,
last_activity_ms: now_ms(),
created_at_ms: now_ms(),
is_hybrid: member.is_hybrid(),
last_seen_seq: 0,
}
};
conv_store
.save_conversation(&conv)
.map_err(|e| SdkError::Storage(e.to_string()))?;
debug!(conv = %conv_id.hex(), "joined group from Welcome");
Ok(conv_id)
}
// ── Member removal ─────────────────────────────────────────────────────────
/// Remove a member from a group.
///
/// Generates an MLS Commit for the removal, sends it via the server RPC,
/// and broadcasts the commit to remaining members.
pub async fn remove_member_from_group(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
conv_id: &ConversationId,
member_identity_key: &[u8],
) -> Result<(), SdkError> {
// 1. MLS removal — generates a commit.
let commit = member
.remove_member(member_identity_key)
.map_err(|e| SdkError::Crypto(format!("remove_member: {e}")))?;
// 2. Call the server-side RemoveMember RPC.
let req = RemoveMemberRequest {
group_id: conv_id.0.to_vec(),
member_identity_key: member_identity_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::REMOVE_MEMBER, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = RemoveMemberResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode RemoveMemberResponse: {e}")))?;
// 3. Broadcast the commit to remaining members.
let remaining = member.member_identities();
for key in &remaining {
enqueue_to_peer(rpc, key, &commit).await?;
}
// 4. Persist updated MLS state.
save_mls_state(conv_store, conv_id, member)?;
debug!(
conv = %conv_id.hex(),
removed = %hex::encode(&member_identity_key[..4.min(member_identity_key.len())]),
"removed member from group"
);
Ok(())
}
// ── Leave group ────────────────────────────────────────────────────────────
/// Leave a group. Generates a removal proposal for self and notifies members.
pub async fn leave_group(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
conv_id: &ConversationId,
) -> Result<(), SdkError> {
let proposal = member
.leave_group()
.map_err(|e| SdkError::Crypto(format!("leave_group: {e}")))?;
// Send the leave proposal to all remaining members so they can commit it.
let members = member.member_identities();
for key in &members {
enqueue_to_peer(rpc, key, &proposal).await?;
}
// Persist updated MLS state (now in a "left" state).
save_mls_state(conv_store, conv_id, member)?;
debug!(conv = %conv_id.hex(), "left group");
Ok(())
}
// ── Key rotation ───────────────────────────────────────────────────────────
/// Rotate group keys — self-update + commit pending proposals.
///
/// Broadcasts the commit to all group members via the server.
pub async fn rotate_group_keys(
rpc: &RpcClient,
conv_store: &ConversationStore,
member: &mut GroupMember,
conv_id: &ConversationId,
) -> Result<(), SdkError> {
// 1. Propose self-update (new leaf key material).
member
.propose_self_update()
.map_err(|e| SdkError::Crypto(format!("propose_self_update: {e}")))?;
// 2. Commit all pending proposals (including the self-update).
let (commit, _welcome) = member
.commit_pending_proposals()
.map_err(|e| SdkError::Crypto(format!("commit_pending_proposals: {e}")))?;
// 3. Call server-side RotateKeys RPC.
let req = RotateKeysRequest {
group_id: conv_id.0.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::ROTATE_KEYS, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = RotateKeysResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode RotateKeysResponse: {e}")))?;
// 4. Broadcast commit to all members.
let members = member.member_identities();
for key in &members {
enqueue_to_peer(rpc, key, &commit).await?;
}
// 5. Persist updated MLS state.
save_mls_state(conv_store, conv_id, member)?;
debug!(conv = %conv_id.hex(), "rotated group keys");
Ok(())
}
// ── Group metadata ─────────────────────────────────────────────────────────
/// Update group metadata (name, description, avatar) on the server.
pub async fn set_group_metadata(
rpc: &RpcClient,
conv_store: &ConversationStore,
conv_id: &ConversationId,
name: &str,
description: &str,
avatar_hash: &[u8],
) -> Result<(), SdkError> {
let req = UpdateGroupMetadataRequest {
group_id: conv_id.0.to_vec(),
name: name.to_string(),
description: description.to_string(),
avatar_hash: avatar_hash.to_vec(),
};
let resp_bytes = rpc
.call(
method_ids::UPDATE_GROUP_METADATA,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = UpdateGroupMetadataResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode UpdateGroupMetadataResponse: {e}")))?;
if !resp.success {
return Err(SdkError::Other(anyhow::anyhow!(
"server rejected metadata update"
)));
}
// Update local conversation display name if name is provided.
if !name.is_empty() {
if let Ok(Some(mut conv)) = conv_store.load_conversation(conv_id) {
conv.display_name = format!("#{name}");
conv.kind = ConversationKind::Group {
name: name.to_string(),
};
let _ = conv_store.save_conversation(&conv);
}
}
debug!(conv = %conv_id.hex(), name = name, "updated group metadata");
Ok(())
}
/// Fetch group members from the server.
pub async fn get_group_members(
rpc: &RpcClient,
conv_id: &ConversationId,
) -> Result<Vec<GroupMemberInfoResult>, SdkError> {
let req = ListGroupMembersRequest {
group_id: conv_id.0.to_vec(),
};
let resp_bytes = rpc
.call(
method_ids::LIST_GROUP_MEMBERS,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = ListGroupMembersResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode ListGroupMembersResponse: {e}")))?;
let members = resp
.members
.into_iter()
.map(|m| GroupMemberInfoResult {
identity_key: m.identity_key,
username: m.username,
joined_at: m.joined_at,
})
.collect();
Ok(members)
}
/// SDK-side group member info returned by [`get_group_members`].
#[derive(Debug, Clone)]
pub struct GroupMemberInfoResult {
pub identity_key: Vec<u8>,
pub username: String,
pub joined_at: u64,
}
// ── MLS state persistence ───────────────────────────────────────────────────
/// Save MLS group state into a conversation record.
pub fn save_mls_state(
conv_store: &ConversationStore,
conv_id: &ConversationId,
member: &GroupMember,
) -> Result<(), SdkError> {
let mut conv = conv_store
.load_conversation(conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
conv.mls_group_blob = member
.group_ref()
.map(bincode::serialize)
.transpose()
.map_err(|e| SdkError::Storage(format!("serialize MLS group: {e}")))?;
conv.member_keys = member.member_identities();
conv.is_hybrid = member.is_hybrid();
conv_store
.save_conversation(&conv)
.map_err(|e| SdkError::Storage(e.to_string()))?;
Ok(())
}
/// Restore a `GroupMember` from a conversation record.
///
/// Returns `Err` if the conversation has no MLS group blob.
pub fn restore_mls_state(
conv: &Conversation,
identity: &Arc<IdentityKeypair>,
) -> Result<GroupMember, SdkError> {
let group_blob = conv
.mls_group_blob
.as_ref()
.ok_or_else(|| SdkError::Crypto("no MLS group blob in conversation".into()))?;
let mls_group = bincode::deserialize(group_blob)
.map_err(|e| SdkError::Crypto(format!("deserialize MLS group: {e}")))?;
let ks = quicprochat_core::DiskKeyStore::ephemeral();
let member = GroupMember::new_with_state(Arc::clone(identity), ks, Some(mls_group), conv.is_hybrid);
Ok(member)
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Optionally wrap data in hybrid encryption.
///
/// If `is_hybrid_mls` is true, MLS already provides PQ protection and we skip
/// the outer envelope.
fn wrap_hybrid(
my_kp: Option<&HybridKeypair>,
peer_pk: Option<&HybridPublicKey>,
data: &[u8],
is_hybrid_mls: bool,
) -> Result<Vec<u8>, SdkError> {
if is_hybrid_mls {
return Ok(data.to_vec());
}
match (my_kp, peer_pk) {
(Some(_), Some(pk)) => {
hybrid_encrypt(pk, data, b"", b"")
.map_err(|e| SdkError::Crypto(format!("hybrid encrypt: {e}")))
}
_ => Ok(data.to_vec()),
}
}
/// Enqueue a payload to a peer via the ENQUEUE RPC.
async fn enqueue_to_peer(
rpc: &RpcClient,
recipient_key: &[u8],
payload: &[u8],
) -> Result<(), SdkError> {
let req = EnqueueRequest {
recipient_key: recipient_key.to_vec(),
payload: payload.to_vec(),
channel_id: Vec::new(),
ttl_secs: 0,
message_id: Vec::new(),
};
let resp_bytes = rpc
.call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = EnqueueResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode EnqueueResponse: {e}")))?;
Ok(())
}

View File

@@ -0,0 +1,109 @@
//! Key management — upload/fetch KeyPackages and hybrid public keys.
use quicprochat_proto::bytes::Bytes;
use quicprochat_proto::prost::Message;
use quicprochat_proto::{method_ids, qpc::v1};
use quicprochat_rpc::client::RpcClient;
use crate::error::SdkError;
/// Upload a KeyPackage for pre-key distribution.
/// Returns the SHA-256 fingerprint echoed by the server.
pub async fn upload_key_package(
rpc: &RpcClient,
identity_key: &[u8],
package: &[u8],
) -> Result<Vec<u8>, SdkError> {
let req = v1::UploadKeyPackageRequest {
identity_key: identity_key.to_vec(),
package: package.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::UPLOAD_KEY_PACKAGE, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::UploadKeyPackageResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode UploadKeyPackageResponse: {e}")))?;
Ok(resp.fingerprint)
}
/// Fetch a KeyPackage for a peer (consumed: single-use).
/// Returns `None` if the peer has no available key packages.
pub async fn fetch_key_package(
rpc: &RpcClient,
identity_key: &[u8],
) -> Result<Option<Vec<u8>>, SdkError> {
let req = v1::FetchKeyPackageRequest {
identity_key: identity_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::FETCH_KEY_PACKAGE, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::FetchKeyPackageResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode FetchKeyPackageResponse: {e}")))?;
if resp.package.is_empty() {
Ok(None)
} else {
Ok(Some(resp.package))
}
}
/// Upload hybrid public key (X25519 + ML-KEM-768).
pub async fn upload_hybrid_key(
rpc: &RpcClient,
identity_key: &[u8],
hybrid_public_key: &[u8],
) -> Result<(), SdkError> {
let req = v1::UploadHybridKeyRequest {
identity_key: identity_key.to_vec(),
hybrid_public_key: hybrid_public_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::UPLOAD_HYBRID_KEY, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = v1::UploadHybridKeyResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode UploadHybridKeyResponse: {e}")))?;
Ok(())
}
/// Fetch a peer's hybrid public key.
/// Returns `None` if the peer has not uploaded a hybrid key.
pub async fn fetch_hybrid_key(
rpc: &RpcClient,
identity_key: &[u8],
) -> Result<Option<Vec<u8>>, SdkError> {
let req = v1::FetchHybridKeyRequest {
identity_key: identity_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::FETCH_HYBRID_KEY, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::FetchHybridKeyResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode FetchHybridKeyResponse: {e}")))?;
if resp.hybrid_public_key.is_empty() {
Ok(None)
} else {
Ok(Some(resp.hybrid_public_key))
}
}
/// Batch fetch hybrid keys for multiple identities.
/// Returns one `Option<Vec<u8>>` per requested identity, in the same order.
pub async fn fetch_hybrid_keys(
rpc: &RpcClient,
identity_keys: &[Vec<u8>],
) -> Result<Vec<Option<Vec<u8>>>, SdkError> {
let req = v1::FetchHybridKeysRequest {
identity_keys: identity_keys.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::FETCH_HYBRID_KEYS, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::FetchHybridKeysResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode FetchHybridKeysResponse: {e}")))?;
let result = resp
.keys
.into_iter()
.map(|k| if k.is_empty() { None } else { Some(k) })
.collect();
Ok(result)
}

View File

@@ -0,0 +1,20 @@
//! Client SDK for quicprochat v2.
//!
//! Provides `QpqClient` — a single entry point for connecting, authenticating,
//! sending/receiving messages, and subscribing to real-time events.
pub mod auth;
pub mod client;
pub mod config;
pub mod conversation;
pub mod devices;
pub mod error;
pub mod events;
pub mod groups;
pub mod keys;
pub mod messaging;
pub mod outbox;
pub mod recovery;
pub mod state;
pub mod transcript;
pub mod users;

View File

@@ -0,0 +1,576 @@
//! Messaging pipeline: send and receive messages through the MLS + sealed sender
//! + hybrid KEM stack.
//!
//! This module wraps the full encryption pipeline:
//! 1. **Send**: serialize → MLS encrypt → sealed sender → hybrid wrap → enqueue
//! 2. **Receive**: fetch → hybrid unwrap → MLS decrypt → unseal → parse
use bytes::Bytes;
use prost::Message;
use tracing::debug;
use quicprochat_core::{
AppMessage, GroupMember, HybridKeypair, HybridPublicKey, IdentityKeypair, ReceivedMessage,
};
use quicprochat_proto::method_ids;
use quicprochat_proto::qpc::v1::{
AckRequest, AckResponse, BatchEnqueueRequest, BatchEnqueueResponse, EnqueueRequest,
EnqueueResponse, FetchRequest, FetchResponse, FetchWaitRequest, FetchWaitResponse,
};
use quicprochat_rpc::client::RpcClient;
use crate::error::SdkError;
// ── Types ─────────────────────────────────────────────────────────────────────
/// A successfully decrypted application message with sender info.
#[derive(Debug)]
pub struct ReceivedPlaintext {
/// Sender's Ed25519 identity key (from sealed sender envelope).
pub sender_key: [u8; 32],
/// The parsed application message (Chat, Reply, Reaction, etc.).
pub message: AppMessage,
/// Server-assigned sequence number.
pub seq: u64,
}
/// Default TTL for enqueued messages (24 hours).
const DEFAULT_TTL_SECS: u32 = 86400;
// ── Send Pipeline ─────────────────────────────────────────────────────────────
/// Encrypt and send a message to a conversation.
///
/// Pipeline: generate_message_id → serialize → MLS encrypt → seal → per-recipient
/// hybrid wrap → batch enqueue.
///
/// Returns the server-assigned sequence numbers (one per recipient).
pub async fn send_message(
rpc: &RpcClient,
member: &mut GroupMember,
identity: &IdentityKeypair,
body: &str,
recipient_keys: &[Vec<u8>],
hybrid_keys: &[Option<HybridPublicKey>],
channel_id: &[u8],
) -> Result<Vec<u64>, SdkError> {
// 1. Generate message ID.
let message_id = quicprochat_core::generate_message_id();
// 2. Serialize application payload.
let serialized = quicprochat_core::serialize_chat(body.as_bytes(), Some(message_id))
.map_err(|e| SdkError::Crypto(format!("serialize_chat: {e}")))?;
// 3. MLS encrypt.
let mls_ciphertext = member
.send_message(&serialized)
.map_err(|e| SdkError::Crypto(format!("MLS encrypt: {e}")))?;
// 4. Sealed sender wrap.
let sealed = quicprochat_core::sealed_sender::seal(identity, &mls_ciphertext);
// 5. Per-recipient hybrid wrap + enqueue.
// If all recipients can share the same payload (no hybrid keys), use batch enqueue.
// Otherwise, enqueue individually with per-recipient hybrid wrapping.
let all_no_hybrid = hybrid_keys.iter().all(|k| k.is_none());
if all_no_hybrid {
// Batch enqueue — same payload for all recipients.
let seqs = batch_enqueue(rpc, recipient_keys, channel_id, &sealed, DEFAULT_TTL_SECS).await?;
debug!(count = seqs.len(), "batch enqueue complete");
Ok(seqs)
} else {
// Per-recipient enqueue with optional hybrid wrapping.
let mut seqs = Vec::with_capacity(recipient_keys.len());
for (i, recipient_key) in recipient_keys.iter().enumerate() {
let payload = if let Some(Some(ref pk)) = hybrid_keys.get(i) {
quicprochat_core::hybrid_encrypt(pk, &sealed, b"", b"")
.map_err(|e| SdkError::Crypto(format!("hybrid encrypt: {e}")))?
} else {
sealed.clone()
};
let seq = enqueue(rpc, recipient_key, channel_id, &payload, DEFAULT_TTL_SECS).await?;
seqs.push(seq);
}
debug!(count = seqs.len(), "per-recipient enqueue complete");
Ok(seqs)
}
}
// ── Receive Pipeline ──────────────────────────────────────────────────────────
/// Receive and decrypt pending messages from the server.
///
/// Pipeline: fetch → sort by seq → for each: hybrid unwrap → MLS decrypt →
/// unseal → parse. Includes retry loop for multi-epoch batches where commits
/// must apply before application messages can be decrypted.
pub async fn receive_messages(
rpc: &RpcClient,
member: &mut GroupMember,
my_identity_key: &[u8],
hybrid_kp: Option<&HybridKeypair>,
channel_id: &[u8],
device_id: &[u8],
) -> Result<Vec<ReceivedPlaintext>, SdkError> {
let payloads = fetch(rpc, my_identity_key, channel_id, 0, device_id).await?;
process_payloads(member, hybrid_kp, payloads)
}
/// Long-poll for new messages with timeout.
///
/// Same pipeline as [`receive_messages`] but uses the FETCH_WAIT RPC which
/// blocks server-side until messages arrive or the timeout expires.
pub async fn receive_messages_wait(
rpc: &RpcClient,
member: &mut GroupMember,
my_identity_key: &[u8],
hybrid_kp: Option<&HybridKeypair>,
channel_id: &[u8],
timeout_ms: u64,
device_id: &[u8],
) -> Result<Vec<ReceivedPlaintext>, SdkError> {
let payloads = fetch_wait(rpc, my_identity_key, channel_id, timeout_ms, device_id).await?;
process_payloads(member, hybrid_kp, payloads)
}
/// Shared processing logic for received payloads.
///
/// Sorts by sequence number, then processes each payload through the decryption
/// pipeline. Uses a retry loop to handle multi-epoch batches where MLS commits
/// must be applied before subsequent application messages can be decrypted.
fn process_payloads(
member: &mut GroupMember,
hybrid_kp: Option<&HybridKeypair>,
mut payloads: Vec<(u64, Vec<u8>)>,
) -> Result<Vec<ReceivedPlaintext>, SdkError> {
if payloads.is_empty() {
return Ok(Vec::new());
}
// Sort by server-assigned sequence number — commits must arrive before
// application messages that depend on the resulting epoch.
payloads.sort_by_key(|(seq, _)| *seq);
let mut results = Vec::new();
let mut pending: Vec<(u64, Vec<u8>)> = Vec::new();
for (seq, raw_payload) in &payloads {
// (a) Try hybrid decrypt; fall back to raw bytes if not hybrid-wrapped.
let mls_bytes = try_hybrid_unwrap(hybrid_kp, raw_payload);
// (b) MLS decrypt.
match member.receive_message(&mls_bytes) {
Ok(ReceivedMessage::Application(plaintext)) => {
if let Some(rp) = try_unseal_and_parse(*seq, &plaintext) {
results.push(rp);
}
}
Ok(ReceivedMessage::StateChanged | ReceivedMessage::SelfRemoved) => {
debug!(seq, "commit/state-change applied");
}
Err(_) => {
// MLS decryption failed — likely an epoch mismatch.
// Stash for retry after commits are applied.
pending.push((*seq, mls_bytes));
}
}
}
// Retry loop: keep retrying pending messages until no more progress.
// This handles multi-epoch batches where commits must apply first.
loop {
let before = pending.len();
pending.retain_mut(|(seq, mls_bytes)| {
match member.receive_message(mls_bytes) {
Ok(ReceivedMessage::Application(plaintext)) => {
if let Some(rp) = try_unseal_and_parse(*seq, &plaintext) {
results.push(rp);
}
false // processed
}
Ok(ReceivedMessage::StateChanged | ReceivedMessage::SelfRemoved) => {
debug!(seq, "commit applied (retry)");
false // processed
}
Err(_) => true, // still pending
}
});
if pending.len() == before {
break; // no progress — remaining messages are unprocessable
}
}
if !pending.is_empty() {
debug!(
remaining = pending.len(),
"unprocessable messages after all retries"
);
}
Ok(results)
}
/// Try to hybrid-decrypt a payload. If the caller has a hybrid keypair, attempt
/// decryption. If it fails (payload might not be hybrid-wrapped), return the
/// raw bytes as-is.
fn try_hybrid_unwrap(hybrid_kp: Option<&HybridKeypair>, payload: &[u8]) -> Vec<u8> {
if let Some(kp) = hybrid_kp {
match quicprochat_core::hybrid_decrypt(kp, payload, b"", b"") {
Ok(inner) => inner,
Err(_) => payload.to_vec(), // not hybrid-wrapped, use raw
}
} else {
payload.to_vec()
}
}
/// Unseal (verify sender identity + Ed25519 signature) then parse the inner
/// application message. Returns None on failure (logged as debug).
fn try_unseal_and_parse(seq: u64, plaintext: &[u8]) -> Option<ReceivedPlaintext> {
let (sender_key, inner) = match quicprochat_core::sealed_sender::unseal(plaintext) {
Ok(pair) => pair,
Err(e) => {
debug!(seq, error = %e, "unseal failed");
return None;
}
};
let (_msg_type, message) = match quicprochat_core::parse(&inner) {
Ok(pair) => pair,
Err(e) => {
debug!(seq, error = %e, "app_message parse failed");
return None;
}
};
Some(ReceivedPlaintext {
sender_key,
message,
seq,
})
}
// ── Gap Detection ────────────────────────────────────────────────────────────
/// A gap detected in server-side sequence numbers.
#[derive(Debug, Clone)]
pub struct SeqGap {
/// The expected next sequence number.
pub expected_seq: u64,
/// The sequence number that was actually received.
pub received_seq: u64,
}
/// Detect gaps in a sorted list of `(seq, payload)` pairs relative to the
/// last known sequence number. Returns a list of gaps and the new highest seq.
///
/// Callers should update their stored `last_seen_seq` to the returned value
/// and emit `ClientEvent::MessageGap` for each gap.
pub fn detect_gaps(last_seen_seq: u64, payloads: &[(u64, Vec<u8>)]) -> (Vec<SeqGap>, u64) {
if payloads.is_empty() {
return (Vec::new(), last_seen_seq);
}
let mut gaps = Vec::new();
let mut expected = last_seen_seq + 1;
for &(seq, _) in payloads {
if seq > expected {
gaps.push(SeqGap {
expected_seq: expected,
received_seq: seq,
});
}
if seq >= expected {
expected = seq + 1;
}
}
// The new last_seen_seq is the highest seq we received.
let new_last_seen = payloads.iter().map(|(s, _)| *s).max().unwrap_or(last_seen_seq);
(gaps, new_last_seen)
}
// ── RPC Helpers ───────────────────────────────────────────────────────────────
/// Enqueue a single payload to one recipient via RPC.
///
/// Returns the server-assigned sequence number.
pub async fn enqueue(
rpc: &RpcClient,
recipient_key: &[u8],
channel_id: &[u8],
payload: &[u8],
ttl_secs: u32,
) -> Result<u64, SdkError> {
let req = EnqueueRequest {
recipient_key: recipient_key.to_vec(),
payload: payload.to_vec(),
channel_id: channel_id.to_vec(),
ttl_secs,
message_id: Vec::new(),
};
let resp_bytes = rpc
.call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec()))
.await?;
let resp = EnqueueResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode EnqueueResponse: {e}")))?;
Ok(resp.seq)
}
/// Batch enqueue the same payload to multiple recipients via RPC.
///
/// Returns per-recipient sequence numbers.
pub async fn batch_enqueue(
rpc: &RpcClient,
recipient_keys: &[Vec<u8>],
channel_id: &[u8],
payload: &[u8],
ttl_secs: u32,
) -> Result<Vec<u64>, SdkError> {
let req = BatchEnqueueRequest {
recipient_keys: recipient_keys.to_vec(),
payload: payload.to_vec(),
channel_id: channel_id.to_vec(),
ttl_secs,
message_id: Vec::new(),
};
let resp_bytes = rpc
.call(
method_ids::BATCH_ENQUEUE,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = BatchEnqueueResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode BatchEnqueueResponse: {e}")))?;
Ok(resp.seqs)
}
/// Fetch messages from server (destructive — removes from queue).
///
/// When `device_id` is non-empty, the server scopes the fetch to the
/// device-specific queue (identity_key + device_id).
///
/// Returns `(seq, payload)` pairs sorted by sequence number.
pub async fn fetch(
rpc: &RpcClient,
my_identity_key: &[u8],
channel_id: &[u8],
limit: u32,
device_id: &[u8],
) -> Result<Vec<(u64, Vec<u8>)>, SdkError> {
let req = FetchRequest {
recipient_key: my_identity_key.to_vec(),
channel_id: channel_id.to_vec(),
limit,
device_id: device_id.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::FETCH, Bytes::from(req.encode_to_vec()))
.await?;
let resp = FetchResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode FetchResponse: {e}")))?;
let mut payloads: Vec<(u64, Vec<u8>)> = resp
.payloads
.into_iter()
.map(|env| (env.seq, env.data))
.collect();
payloads.sort_by_key(|(seq, _)| *seq);
Ok(payloads)
}
/// Long-poll fetch: blocks server-side until messages arrive or timeout expires.
///
/// When `device_id` is non-empty, the server scopes the fetch to the
/// device-specific queue (identity_key + device_id).
///
/// Returns `(seq, payload)` pairs sorted by sequence number.
async fn fetch_wait(
rpc: &RpcClient,
my_identity_key: &[u8],
channel_id: &[u8],
timeout_ms: u64,
device_id: &[u8],
) -> Result<Vec<(u64, Vec<u8>)>, SdkError> {
let req = FetchWaitRequest {
recipient_key: my_identity_key.to_vec(),
channel_id: channel_id.to_vec(),
timeout_ms,
limit: 0, // fetch all
device_id: device_id.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::FETCH_WAIT, Bytes::from(req.encode_to_vec()))
.await?;
let resp = FetchWaitResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode FetchWaitResponse: {e}")))?;
let mut payloads: Vec<(u64, Vec<u8>)> = resp
.payloads
.into_iter()
.map(|env| (env.seq, env.data))
.collect();
payloads.sort_by_key(|(seq, _)| *seq);
Ok(payloads)
}
// ── Device-aware fetch ──────────────────────────────────────────────────────
/// Fetch messages for a specific device.
///
/// When `device_id` is non-empty, the server uses the composite queue key
/// `identity_key + device_id`. When empty, falls back to the bare identity key.
pub async fn fetch_for_device(
rpc: &RpcClient,
my_identity_key: &[u8],
device_id: &[u8],
channel_id: &[u8],
limit: u32,
) -> Result<Vec<(u64, Vec<u8>)>, SdkError> {
let req = FetchRequest {
recipient_key: my_identity_key.to_vec(),
channel_id: channel_id.to_vec(),
limit,
device_id: device_id.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::FETCH, Bytes::from(req.encode_to_vec()))
.await?;
let resp = FetchResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode FetchResponse: {e}")))?;
let mut payloads: Vec<(u64, Vec<u8>)> = resp
.payloads
.into_iter()
.map(|env| (env.seq, env.data))
.collect();
payloads.sort_by_key(|(seq, _)| *seq);
Ok(payloads)
}
// ── Acknowledge ─────────────────────────────────────────────────────────────
/// Acknowledge messages up to a sequence number.
///
/// When `device_id` is non-empty, the server acks on the device-scoped queue.
pub async fn ack(
rpc: &RpcClient,
my_identity_key: &[u8],
device_id: &[u8],
channel_id: &[u8],
seq_up_to: u64,
) -> Result<(), SdkError> {
let req = AckRequest {
recipient_key: my_identity_key.to_vec(),
channel_id: channel_id.to_vec(),
seq_up_to,
device_id: device_id.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::ACK, Bytes::from(req.encode_to_vec()))
.await?;
let _resp = AckResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode AckResponse: {e}")))?;
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn detect_gaps_empty() {
let (gaps, last) = detect_gaps(0, &[]);
assert!(gaps.is_empty());
assert_eq!(last, 0);
}
#[test]
fn detect_gaps_contiguous_from_zero() {
let payloads = vec![
(1, vec![]),
(2, vec![]),
(3, vec![]),
];
let (gaps, last) = detect_gaps(0, &payloads);
assert!(gaps.is_empty());
assert_eq!(last, 3);
}
#[test]
fn detect_gaps_contiguous_from_nonzero() {
let payloads = vec![
(6, vec![]),
(7, vec![]),
(8, vec![]),
];
let (gaps, last) = detect_gaps(5, &payloads);
assert!(gaps.is_empty());
assert_eq!(last, 8);
}
#[test]
fn detect_gaps_single_gap() {
let payloads = vec![
(1, vec![]),
(2, vec![]),
(5, vec![]), // gap: expected 3, got 5
(6, vec![]),
];
let (gaps, last) = detect_gaps(0, &payloads);
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0].expected_seq, 3);
assert_eq!(gaps[0].received_seq, 5);
assert_eq!(last, 6);
}
#[test]
fn detect_gaps_multiple_gaps() {
let payloads = vec![
(3, vec![]), // gap from 1 to 3
(7, vec![]), // gap from 4 to 7
(8, vec![]),
];
let (gaps, last) = detect_gaps(0, &payloads);
assert_eq!(gaps.len(), 2);
assert_eq!(gaps[0].expected_seq, 1);
assert_eq!(gaps[0].received_seq, 3);
assert_eq!(gaps[1].expected_seq, 4);
assert_eq!(gaps[1].received_seq, 7);
assert_eq!(last, 8);
}
#[test]
fn detect_gaps_initial_gap() {
// last_seen_seq = 5, but first received is 10
let payloads = vec![
(10, vec![]),
(11, vec![]),
];
let (gaps, last) = detect_gaps(5, &payloads);
assert_eq!(gaps.len(), 1);
assert_eq!(gaps[0].expected_seq, 6);
assert_eq!(gaps[0].received_seq, 10);
assert_eq!(last, 11);
}
}

View File

@@ -0,0 +1,133 @@
//! Offline outbox — queue messages for deferred delivery.
//!
//! When the client is disconnected or an enqueue RPC fails, messages are
//! persisted in the local SQLCipher outbox table. On reconnect, `flush_outbox`
//! retries each pending entry with exponential backoff, up to `MAX_RETRIES`.
use bytes::Bytes;
use prost::Message;
use tracing::{debug, info, warn};
use quicprochat_proto::method_ids;
use quicprochat_proto::qpc::v1::{EnqueueRequest, EnqueueResponse};
use quicprochat_rpc::client::RpcClient;
use crate::conversation::{ConversationId, ConversationStore};
use crate::error::SdkError;
/// Maximum retry attempts before marking an entry as permanently failed.
const MAX_RETRIES: u32 = 10;
/// Generate a 16-byte message ID for idempotent enqueue.
///
/// Uses random bytes (no UUID v7 dependency). The server uses this for dedup.
pub fn generate_message_id() -> Vec<u8> {
let mut id = vec![0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut id);
id
}
/// Queue a message for sending when connectivity is restored.
pub fn queue_outbox(
conv_store: &ConversationStore,
conv_id: &ConversationId,
recipient_key: &[u8],
payload: &[u8],
) -> Result<(), SdkError> {
conv_store
.enqueue_outbox(conv_id, recipient_key, payload)
.map_err(|e| SdkError::Storage(format!("enqueue outbox: {e}")))
}
/// Process all pending outbox entries — send them to the server.
///
/// Uses exponential backoff delay between retries (1s base, max 60s).
/// Returns `(sent, failed)` counts.
pub async fn flush_outbox(
rpc: &RpcClient,
conv_store: &ConversationStore,
) -> Result<(usize, usize), SdkError> {
let entries = conv_store
.load_pending_outbox()
.map_err(|e| SdkError::Storage(format!("load outbox: {e}")))?;
if entries.is_empty() {
return Ok((0, 0));
}
info!(pending = entries.len(), "flushing outbox");
let mut sent = 0usize;
let mut failed = 0usize;
for entry in &entries {
// Generate a message_id for idempotent retry.
let message_id = generate_message_id();
let req = EnqueueRequest {
recipient_key: entry.recipient_key.clone(),
payload: entry.payload.clone(),
channel_id: entry.conversation_id.0.to_vec(),
ttl_secs: 0,
message_id,
};
match rpc
.call(method_ids::ENQUEUE, Bytes::from(req.encode_to_vec()))
.await
{
Ok(resp_bytes) => {
match EnqueueResponse::decode(resp_bytes) {
Ok(resp) => {
if resp.duplicate {
debug!(outbox_id = entry.id, "duplicate enqueue (idempotent)");
}
}
Err(e) => {
warn!(outbox_id = entry.id, "decode enqueue response: {e}");
}
}
conv_store
.mark_outbox_sent(entry.id)
.map_err(|e| SdkError::Storage(format!("mark_outbox_sent: {e}")))?;
sent += 1;
debug!(outbox_id = entry.id, "outbox entry sent");
}
Err(e) => {
let new_count = entry.retry_count + 1;
if new_count > MAX_RETRIES {
warn!(outbox_id = entry.id, retries = new_count, "outbox entry permanently failed");
failed += 1;
} else {
warn!(outbox_id = entry.id, retries = new_count, "outbox send failed: {e}");
}
conv_store
.mark_outbox_failed(entry.id, new_count)
.map_err(|e| SdkError::Storage(format!("mark_outbox_failed: {e}")))?;
}
}
}
info!(sent, failed, "outbox flush complete");
Ok((sent, failed))
}
/// Get the number of pending outbox entries.
pub fn outbox_count(conv_store: &ConversationStore) -> Result<usize, SdkError> {
conv_store
.count_pending_outbox()
.map_err(|e| SdkError::Storage(format!("count outbox: {e}")))
}
/// List pending outbox entries for display.
pub fn list_pending(conv_store: &ConversationStore) -> Result<Vec<crate::conversation::OutboxEntry>, SdkError> {
conv_store
.load_pending_outbox()
.map_err(|e| SdkError::Storage(format!("load outbox: {e}")))
}
/// Clear all permanently failed outbox entries.
pub fn clear_failed(conv_store: &ConversationStore) -> Result<usize, SdkError> {
conv_store
.clear_failed_outbox()
.map_err(|e| SdkError::Storage(format!("clear failed outbox: {e}")))
}

View File

@@ -0,0 +1,119 @@
//! Account recovery — setup, upload, and restore via recovery codes.
//!
//! Wraps `quicprochat_core::recovery` and the v2 RPC recovery service.
use bytes::Bytes;
use prost::Message;
use quicprochat_core::recovery::{
generate_recovery_codes, recover_from_bundle, recovery_token_hash, RecoveryBundle,
};
use quicprochat_proto::{method_ids, qpc::v1};
use quicprochat_rpc::client::RpcClient;
use crate::error::SdkError;
/// Set up account recovery: generate codes, encrypt bundles, upload to server.
///
/// Returns the recovery codes (display to user once, never store).
pub async fn setup_recovery(
rpc: &RpcClient,
identity_seed: &[u8; 32],
conversation_ids: &[Vec<u8>],
) -> Result<Vec<String>, SdkError> {
let setup = generate_recovery_codes(identity_seed, conversation_ids)
.map_err(|e| SdkError::Crypto(format!("recovery code generation: {e}")))?;
// Upload each encrypted bundle to the server.
for bundle in &setup.bundles {
let bundle_bytes = bincode::serialize(bundle)
.map_err(|e| SdkError::Crypto(format!("serialize recovery bundle: {e}")))?;
let req = v1::StoreRecoveryBundleRequest {
token_hash: bundle.token_hash.clone(),
bundle: bundle_bytes,
ttl_secs: 0, // Use server default (90 days).
};
let resp_bytes = rpc
.call(
method_ids::STORE_RECOVERY_BUNDLE,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = v1::StoreRecoveryBundleResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode store_recovery response: {e}")))?;
if !resp.success {
return Err(SdkError::Crypto(
"server rejected recovery bundle upload".into(),
));
}
}
Ok(setup.codes)
}
/// Recover an account from a recovery code.
///
/// Fetches the encrypted bundle from the server, decrypts it with the code,
/// and returns the identity seed and conversation IDs.
pub async fn recover_account(
rpc: &RpcClient,
code: &str,
) -> Result<(/* identity_seed */ [u8; 32], /* conversation_ids */ Vec<Vec<u8>>), SdkError> {
// Compute the token hash for server-side lookup.
let token_hash = recovery_token_hash(code);
let req = v1::FetchRecoveryBundleRequest {
token_hash: token_hash.clone(),
};
let resp_bytes = rpc
.call(
method_ids::FETCH_RECOVERY_BUNDLE,
Bytes::from(req.encode_to_vec()),
)
.await?;
let resp = v1::FetchRecoveryBundleResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode fetch_recovery response: {e}")))?;
if resp.bundle.is_empty() {
return Err(SdkError::Crypto(
"no recovery bundle found for this code".into(),
));
}
// Deserialize the bundle.
let bundle: RecoveryBundle = bincode::deserialize(&resp.bundle)
.map_err(|e| SdkError::Crypto(format!("deserialize recovery bundle: {e}")))?;
// Decrypt with the code.
let payload = recover_from_bundle(code, &bundle)
.map_err(|e| SdkError::Crypto(format!("recovery decryption failed: {e}")))?;
Ok((payload.identity_seed, payload.conversation_ids))
}
/// Delete all recovery bundles for the given codes (e.g. after refresh).
pub async fn delete_recovery_bundles(
rpc: &RpcClient,
codes: &[String],
) -> Result<(), SdkError> {
for code in codes {
let token_hash = recovery_token_hash(code);
let req = v1::DeleteRecoveryBundleRequest { token_hash };
let resp_bytes = rpc
.call(
method_ids::DELETE_RECOVERY_BUNDLE,
Bytes::from(req.encode_to_vec()),
)
.await?;
let _resp = v1::DeleteRecoveryBundleResponse::decode(resp_bytes)
.map_err(|e| SdkError::Crypto(format!("decode delete_recovery response: {e}")))?;
}
Ok(())
}

View File

@@ -0,0 +1,228 @@
//! Encrypted client state — identity key, MLS group, and hybrid KEM persistence.
//!
//! State is stored with the QPCE format:
//! `QPCE` magic (4 bytes) + salt (16 bytes) + nonce (12 bytes) + ciphertext.
//! Key derivation uses Argon2id (m=19456 KiB, t=2, p=1).
use std::path::Path;
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::error::SdkError;
/// Magic bytes for encrypted client state files.
const STATE_MAGIC: &[u8; 4] = b"QPCE";
const STATE_SALT_LEN: usize = 16;
const STATE_NONCE_LEN: usize = 12;
/// Argon2id parameters for client state key derivation.
const ARGON2_STATE_M_COST: u32 = 19 * 1024;
const ARGON2_STATE_T_COST: u32 = 2;
const ARGON2_STATE_P_COST: u32 = 1;
/// Encrypted client state (identity + MLS group + hybrid keys).
#[derive(Serialize, Deserialize)]
pub struct StoredState {
pub identity_seed: [u8; 32],
pub group: Option<Vec<u8>>,
pub hybrid_key: Option<Vec<u8>>,
#[serde(default)]
pub member_keys: Vec<Vec<u8>>,
}
/// Derive a 32-byte key from a password and salt using Argon2id.
fn derive_state_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>, SdkError> {
let params = Params::new(ARGON2_STATE_M_COST, ARGON2_STATE_T_COST, ARGON2_STATE_P_COST, Some(32))
.map_err(|e| SdkError::Crypto(format!("argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params);
let mut key = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| SdkError::Crypto(format!("argon2 key derivation failed: {e}")))?;
Ok(Zeroizing::new(key))
}
/// Encrypt `plaintext` with the QPCE format: magic(4) | salt(16) | nonce(12) | ciphertext.
pub fn encrypt_state(password: &str, plaintext: &[u8]) -> Result<Vec<u8>, SdkError> {
let mut salt = [0u8; STATE_SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; STATE_NONCE_LEN];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_state_key(password, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key));
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| SdkError::Crypto(format!("state encryption failed: {e}")))?;
let mut out = Vec::with_capacity(4 + STATE_SALT_LEN + STATE_NONCE_LEN + ciphertext.len());
out.extend_from_slice(STATE_MAGIC);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt a QPCE-formatted state file.
pub fn decrypt_state(password: &str, data: &[u8]) -> Result<Vec<u8>, SdkError> {
let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN;
if data.len() <= header_len {
return Err(SdkError::Crypto(format!(
"encrypted state file too short ({} bytes)",
data.len()
)));
}
if &data[..4] != STATE_MAGIC {
return Err(SdkError::Crypto("invalid state file magic header".into()));
}
let salt = &data[4..4 + STATE_SALT_LEN];
let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len];
let ciphertext = &data[header_len..];
let key = derive_state_key(password, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key));
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| SdkError::Crypto("state decryption failed (wrong password?)".into()))
}
/// Save state to disk, optionally encrypted with `password`.
pub fn save_state(path: &Path, state: &StoredState, password: Option<&str>) -> Result<(), SdkError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| SdkError::Storage(format!("create dir {}: {e}", parent.display())))?;
}
let plaintext = bincode::serialize(state)
.map_err(|e| SdkError::Storage(format!("encode state: {e}")))?;
let bytes = match password {
Some(pw) => encrypt_state(pw, &plaintext)?,
None => plaintext,
};
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &bytes)
.map_err(|e| SdkError::Storage(format!("write state temp {}: {e}", tmp.display())))?;
std::fs::rename(&tmp, path)
.map_err(|e| SdkError::Storage(format!("rename state: {e}")))?;
Ok(())
}
/// Load state from disk, decrypting if necessary.
pub fn load_state(path: &Path, password: Option<&str>) -> Result<StoredState, SdkError> {
let bytes = std::fs::read(path)
.map_err(|e| SdkError::Storage(format!("read state file {}: {e}", path.display())))?;
let is_encrypted = bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC;
if is_encrypted {
let pw = password
.ok_or_else(|| SdkError::Crypto("state file is encrypted; password required".into()))?;
let plaintext = decrypt_state(pw, &bytes)?;
bincode::deserialize(&plaintext)
.map_err(|e| SdkError::Storage(format!("decode encrypted state: {e}")))
} else {
bincode::deserialize(&bytes)
.map_err(|e| SdkError::Storage(format!("decode state: {e}")))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_roundtrip() {
let plaintext = b"test state data";
let password = "test-password";
let encrypted = encrypt_state(password, plaintext).unwrap();
assert_eq!(&encrypted[..4], STATE_MAGIC);
let decrypted = decrypt_state(password, &encrypted).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn wrong_password_fails() {
let plaintext = b"test state data";
let encrypted = encrypt_state("correct", plaintext).unwrap();
assert!(decrypt_state("wrong", &encrypted).is_err());
}
#[test]
fn state_serialize_roundtrip() {
let state = StoredState {
identity_seed: [42u8; 32],
group: None,
hybrid_key: None,
member_keys: Vec::new(),
};
let password = "test-password";
let plaintext = bincode::serialize(&state).unwrap();
let encrypted = encrypt_state(password, &plaintext).unwrap();
let decrypted = decrypt_state(password, &encrypted).unwrap();
let recovered: StoredState = bincode::deserialize(&decrypted).unwrap();
assert_eq!(recovered.identity_seed, state.identity_seed);
assert!(recovered.group.is_none());
assert!(recovered.hybrid_key.is_none());
}
#[test]
fn save_load_roundtrip() {
let dir = std::env::temp_dir().join("qpc_sdk_state_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.state");
let state = StoredState {
identity_seed: [7u8; 32],
group: Some(vec![1, 2, 3]),
hybrid_key: Some(vec![4, 5, 6]),
member_keys: vec![vec![10, 11]],
};
// Test unencrypted.
save_state(&path, &state, None).unwrap();
let loaded = load_state(&path, None).unwrap();
assert_eq!(loaded.identity_seed, state.identity_seed);
assert_eq!(loaded.group, state.group);
// Test encrypted.
save_state(&path, &state, Some("pw")).unwrap();
let loaded = load_state(&path, Some("pw")).unwrap();
assert_eq!(loaded.identity_seed, state.identity_seed);
assert_eq!(loaded.hybrid_key, state.hybrid_key);
// Encrypted but no password fails.
assert!(load_state(&path, None).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn too_short_data_fails() {
assert!(decrypt_state("pw", b"QPCE").is_err());
assert!(decrypt_state("pw", &[]).is_err());
}
#[test]
fn invalid_magic_fails() {
let mut data = vec![0u8; 100];
data[..4].copy_from_slice(b"NOPE");
assert!(decrypt_state("pw", &data).is_err());
}
}

View File

@@ -0,0 +1,187 @@
//! Transcript archive export and verification.
//!
//! Wraps `quicprochat_core::transcript` to provide SDK-level functions for
//! exporting conversation messages to an encrypted, tamper-evident archive
//! and verifying archive integrity.
use std::fs;
use std::path::Path;
use quicprochat_core::transcript::{
read_transcript, validate_transcript_structure, ChainVerdict, TranscriptRecord,
TranscriptWriter,
};
use crate::conversation::{ConversationId, ConversationStore};
/// Export all messages in a conversation to an encrypted, tamper-evident archive.
///
/// Uses Argon2id key derivation from `password` and ChaCha20-Poly1305 for AEAD
/// encryption. Each record is chained via SHA-256 of the previous ciphertext.
///
/// Returns the number of records written.
pub fn export_transcript(
store: &ConversationStore,
conversation_id: &ConversationId,
path: &Path,
password: &str,
) -> anyhow::Result<u64> {
// Use a large limit to get all messages (sorted oldest-first by load_recent_messages).
let messages = store.load_recent_messages(conversation_id, usize::MAX)?;
let mut buf = Vec::new();
let mut writer = TranscriptWriter::new(password, &mut buf)
.map_err(|e| anyhow::anyhow!("create transcript writer: {e}"))?;
let mut count = 0u64;
for msg in &messages {
let sender_key = if msg.sender_key.len() == 32 {
&msg.sender_key[..]
} else {
&[0u8; 32][..]
};
writer
.write_record(
&TranscriptRecord {
seq: count,
sender_identity: sender_key,
timestamp_ms: msg.timestamp_ms,
plaintext: &msg.body,
},
&mut buf,
)
.map_err(|e| anyhow::anyhow!("write record {count}: {e}"))?;
count += 1;
}
fs::write(path, &buf)?;
Ok(count)
}
/// Verify an exported transcript archive.
///
/// If `password` is provided, performs full decryption and hash-chain
/// verification. Otherwise, only checks structural integrity (file format,
/// record boundaries).
///
/// Returns the chain verdict.
pub fn verify_transcript(
path: &Path,
password: Option<&str>,
) -> anyhow::Result<ChainVerdict> {
let data = fs::read(path)?;
if let Some(pw) = password {
let (_records, verdict) = read_transcript(pw, &data)
.map_err(|e| anyhow::anyhow!("verify transcript: {e}"))?;
Ok(verdict)
} else {
let verdict = validate_transcript_structure(&data)
.map_err(|e| anyhow::anyhow!("validate structure: {e}"))?;
Ok(verdict)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::conversation::{Conversation, ConversationKind, StoredMessage};
use tempfile::TempDir;
fn setup() -> (TempDir, ConversationStore) {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("test.db");
let store = ConversationStore::open(&db_path, None).unwrap();
(dir, store)
}
fn save_conv(store: &ConversationStore, conv_id: &ConversationId, name: &str) {
let conv = Conversation {
id: conv_id.clone(),
kind: ConversationKind::Group {
name: name.to_string(),
},
display_name: name.to_string(),
mls_group_blob: None,
keystore_blob: None,
member_keys: vec![],
unread_count: 0,
last_activity_ms: 0,
created_at_ms: 0,
is_hybrid: false,
last_seen_seq: 0,
};
store.save_conversation(&conv).unwrap();
}
fn save_msg(store: &ConversationStore, conv_id: &ConversationId, body: &str, ts: u64) {
let msg = StoredMessage {
conversation_id: conv_id.clone(),
message_id: None,
sender_key: vec![0xAAu8; 32],
sender_name: None,
body: body.to_string(),
msg_type: "chat".to_string(),
ref_msg_id: None,
timestamp_ms: ts,
is_outgoing: false,
};
store.save_message(&msg).unwrap();
}
#[test]
fn export_and_verify_round_trip() {
let (dir, store) = setup();
let conv_id = ConversationId::from_group_name("test-group");
save_conv(&store, &conv_id, "test-group");
save_msg(&store, &conv_id, "Hello!", 1000);
save_msg(&store, &conv_id, "World!", 2000);
let archive_path = dir.path().join("transcript.qpct");
let count = export_transcript(&store, &conv_id, &archive_path, "test-pw").unwrap();
assert_eq!(count, 2);
// Full verification with password.
let verdict = verify_transcript(&archive_path, Some("test-pw")).unwrap();
assert_eq!(verdict, ChainVerdict::Ok { records: 2 });
// Structural verification without password.
let verdict = verify_transcript(&archive_path, None).unwrap();
assert_eq!(verdict, ChainVerdict::Ok { records: 2 });
}
#[test]
fn wrong_password_fails_verification() {
let (dir, store) = setup();
let conv_id = ConversationId::from_group_name("pw-test");
save_conv(&store, &conv_id, "pw-test");
save_msg(&store, &conv_id, "secret", 1000);
let archive_path = dir.path().join("transcript_pw.qpct");
export_transcript(&store, &conv_id, &archive_path, "correct").unwrap();
let result = verify_transcript(&archive_path, Some("wrong"));
assert!(result.is_err());
}
#[test]
fn empty_conversation_exports_empty_archive() {
let (dir, store) = setup();
let conv_id = ConversationId::from_group_name("empty");
save_conv(&store, &conv_id, "empty");
let archive_path = dir.path().join("empty.qpct");
let count = export_transcript(&store, &conv_id, &archive_path, "pw").unwrap();
assert_eq!(count, 0);
let verdict = verify_transcript(&archive_path, Some("pw")).unwrap();
assert_eq!(verdict, ChainVerdict::Ok { records: 0 });
}
}

View File

@@ -0,0 +1,50 @@
//! User resolution — username <-> identity key lookups.
use quicprochat_proto::bytes::Bytes;
use quicprochat_proto::prost::Message;
use quicprochat_proto::{method_ids, qpc::v1};
use quicprochat_rpc::client::RpcClient;
use crate::error::SdkError;
/// Resolve a username to its identity key.
/// Returns `None` if the username is not registered.
pub async fn resolve_user(
rpc: &RpcClient,
username: &str,
) -> Result<Option<Vec<u8>>, SdkError> {
let req = v1::ResolveUserRequest {
username: username.to_string(),
};
let resp_bytes = rpc
.call(method_ids::RESOLVE_USER, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::ResolveUserResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode ResolveUserResponse: {e}")))?;
if resp.identity_key.is_empty() {
Ok(None)
} else {
Ok(Some(resp.identity_key))
}
}
/// Reverse lookup: identity key to username.
/// Returns `None` if no username is associated with the key.
pub async fn resolve_identity(
rpc: &RpcClient,
identity_key: &[u8],
) -> Result<Option<String>, SdkError> {
let req = v1::ResolveIdentityRequest {
identity_key: identity_key.to_vec(),
};
let resp_bytes = rpc
.call(method_ids::RESOLVE_IDENTITY, Bytes::from(req.encode_to_vec()))
.await?;
let resp = v1::ResolveIdentityResponse::decode(resp_bytes)
.map_err(|e| SdkError::Other(anyhow::anyhow!("decode ResolveIdentityResponse: {e}")))?;
if resp.username.is_empty() {
Ok(None)
} else {
Ok(Some(resp.username))
}
}