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:
39
crates/quicprochat-sdk/Cargo.toml
Normal file
39
crates/quicprochat-sdk/Cargo.toml
Normal file
@@ -0,0 +1,39 @@
|
||||
[package]
|
||||
name = "quicprochat-sdk"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Client SDK for quicprochat v2 — connect, auth, send, receive, subscribe"
|
||||
license = "Apache-2.0 OR MIT"
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
quicprochat-core = { path = "../quicprochat-core" }
|
||||
quicprochat-proto = { path = "../quicprochat-proto" }
|
||||
quicprochat-rpc = { path = "../quicprochat-rpc" }
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bincode = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
quinn = { workspace = true }
|
||||
opaque-ke = { workspace = true }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
prost = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
142
crates/quicprochat-sdk/src/auth.rs
Normal file
142
crates/quicprochat-sdk/src/auth.rs
Normal 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)
|
||||
}
|
||||
348
crates/quicprochat-sdk/src/client.rs
Normal file
348
crates/quicprochat-sdk/src/client.rs
Normal 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,
|
||||
]
|
||||
}
|
||||
}
|
||||
47
crates/quicprochat-sdk/src/config.rs
Normal file
47
crates/quicprochat-sdk/src/config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
1041
crates/quicprochat-sdk/src/conversation.rs
Normal file
1041
crates/quicprochat-sdk/src/conversation.rs
Normal file
File diff suppressed because it is too large
Load Diff
73
crates/quicprochat-sdk/src/devices.rs
Normal file
73
crates/quicprochat-sdk/src/devices.rs
Normal 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)
|
||||
}
|
||||
29
crates/quicprochat-sdk/src/error.rs
Normal file
29
crates/quicprochat-sdk/src/error.rs
Normal 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),
|
||||
}
|
||||
84
crates/quicprochat-sdk/src/events.rs
Normal file
84
crates/quicprochat-sdk/src/events.rs
Normal 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 },
|
||||
}
|
||||
589
crates/quicprochat-sdk/src/groups.rs
Normal file
589
crates/quicprochat-sdk/src/groups.rs
Normal 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(())
|
||||
}
|
||||
109
crates/quicprochat-sdk/src/keys.rs
Normal file
109
crates/quicprochat-sdk/src/keys.rs
Normal 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)
|
||||
}
|
||||
20
crates/quicprochat-sdk/src/lib.rs
Normal file
20
crates/quicprochat-sdk/src/lib.rs
Normal 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;
|
||||
576
crates/quicprochat-sdk/src/messaging.rs
Normal file
576
crates/quicprochat-sdk/src/messaging.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
133
crates/quicprochat-sdk/src/outbox.rs
Normal file
133
crates/quicprochat-sdk/src/outbox.rs
Normal 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}")))
|
||||
}
|
||||
119
crates/quicprochat-sdk/src/recovery.rs
Normal file
119
crates/quicprochat-sdk/src/recovery.rs
Normal 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(())
|
||||
}
|
||||
228
crates/quicprochat-sdk/src/state.rs
Normal file
228
crates/quicprochat-sdk/src/state.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
187
crates/quicprochat-sdk/src/transcript.rs
Normal file
187
crates/quicprochat-sdk/src/transcript.rs
Normal 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 });
|
||||
}
|
||||
}
|
||||
50
crates/quicprochat-sdk/src/users.rs
Normal file
50
crates/quicprochat-sdk/src/users.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user