//! `QpqClient` — the main entry point for the quicproquo 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, event_tx: broadcast::Sender, /// The authenticated username, if logged in. username: Option, /// The local identity key (Ed25519 public key, 32 bytes). identity_key: Option>, /// Session token from OPAQUE login. session_token: Option>, /// Local conversation store (SQLCipher). conv_store: Option, /// 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>, } 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 = quicproquo_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 = quicproquo_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 { 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<&quicproquo_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) { 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) { 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 { 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, 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 { 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 { 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 { 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, 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 { 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 { Ok(rustls::client::danger::ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { 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, ] } }