commit f511903a5d03003b6cee5a50ecd683708e3c2e70 Author: Christian Nennemann Date: Sun Feb 22 18:56:27 2026 +0100 feat: add Python (PyO3) and Ruby (Magnus) native bindings Introduces three crates: - quicnprotochat-bindings: shared Rust API returning structured data instead of printing to stdout; explicit per-call auth (no global OnceLock); QPCE state files fully interoperable with the CLI. - quicnprotochat-python: PyO3 0.22 extension; GIL released during all blocking QUIC calls via py.allow_threads. - quicnprotochat-ruby: Magnus 0.7 extension; Rakefile build task. Core/proto crates referenced via git dep on the main repo. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ccb1273 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "2" +members = [ + "crates/quicnprotochat-bindings", + "crates/quicnprotochat-python", + "crates/quicnprotochat-ruby", +] + +# ── Shared dependency versions ──────────────────────────────────────────────── +[workspace.dependencies] +# ── Crypto ─────────────────────────────────────────────────────────────────── +openmls = { version = "0.5", default-features = false, features = ["crypto-subtle"] } +openmls_rust_crypto = { version = "0.2" } +openmls_traits = { version = "0.2" } +tls_codec = { version = "0.3", features = ["derive"] } +ml-kem = { version = "0.2" } +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = { version = "2", features = ["rand_core"] } +sha2 = { version = "0.10" } +hkdf = { version = "0.12" } +chacha20poly1305 = { version = "0.10" } +opaque-ke = { version = "4", features = ["ristretto255", "argon2"] } +zeroize = { version = "1", features = ["derive", "serde"] } +subtle = { version = "2" } +argon2 = { version = "0.5" } +rand = { version = "0.8" } +serde = { version = "1", features = ["derive"] } +bincode = { version = "1" } + +# ── Serialisation + RPC ─────────────────────────────────────────────────────── +capnp = { version = "0.19" } +capnp-rpc = { version = "0.19" } + +# ── Async / networking ──────────────────────────────────────────────────────── +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec", "compat"] } +futures = { version = "0.3" } +quinn = { version = "0.11" } +quinn-proto = { version = "0.11" } +rustls = { version = "0.23", default-features = false, features = ["std", "ring"] } + +# ── Error handling ──────────────────────────────────────────────────────────── +anyhow = { version = "1" } +thiserror = { version = "1" } diff --git a/crates/quicnprotochat-bindings/Cargo.toml b/crates/quicnprotochat-bindings/Cargo.toml new file mode 100644 index 0000000..14f145c --- /dev/null +++ b/crates/quicnprotochat-bindings/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "quicnprotochat-bindings" +version = "0.1.0" +edition = "2021" +description = "Language-agnostic Rust library powering Python and Ruby native bindings." +license = "MIT" + +[lib] +name = "quicnprotochat_bindings" +crate-type = ["rlib"] + +[dependencies] +quicnprotochat-core = { git = "git@git.xorwell.de:c/quicnprotochat.git" } +quicnprotochat-proto = { git = "git@git.xorwell.de:c/quicnprotochat.git" } +openmls_rust_crypto = { workspace = true } + +# Serialisation + RPC +capnp = { workspace = true } +capnp-rpc = { workspace = true } + +# Async / networking +tokio = { workspace = true } +tokio-util = { workspace = true } +futures = { workspace = true } +quinn = { workspace = true } +quinn-proto = { workspace = true } +rustls = { workspace = true } + +# Serialisation +serde = { workspace = true } +bincode = { workspace = true } + +# Crypto — OPAQUE PAKE +opaque-ke = { workspace = true } +rand = { workspace = true } + +# State encryption +argon2 = { workspace = true } +chacha20poly1305 = { workspace = true } +sha2 = { workspace = true } + +# Error handling +anyhow = { workspace = true } +thiserror = { workspace = true } + +# Hex encoding for public return values +hex = "0.4" diff --git a/crates/quicnprotochat-bindings/src/lib.rs b/crates/quicnprotochat-bindings/src/lib.rs new file mode 100644 index 0000000..19565bc --- /dev/null +++ b/crates/quicnprotochat-bindings/src/lib.rs @@ -0,0 +1,536 @@ +//! `quicnprotochat-bindings` — language-agnostic Rust API. +//! +//! This crate provides a `Client` struct whose methods return structured data +//! instead of printing to stdout, making it straightforward to wrap with PyO3 +//! (Python) or Magnus (Ruby). +//! +//! Each method opens a fresh QUIC connection, performs the operation, and +//! returns. State is persisted to disk between calls (same QPCE format as the +//! CLI, so state files are fully interoperable). + +mod rpc; +mod state; + +use std::path::{Path, PathBuf}; + +use anyhow::Context; +use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, + ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, +}; +use quicnprotochat_core::{hybrid_encrypt, opaque_auth::OpaqueSuite, IdentityKeypair}; + +use rpc::{ + connect_node, current_timestamp_ms, enqueue, fetch_all, fetch_hybrid_key, fetch_key_package, + fetch_wait, try_hybrid_decrypt, upload_hybrid_key, upload_key_package, +}; +use state::{decode_identity_key, load_existing_state, load_or_init_state, save_state, sha256}; + +// ── Public return types ────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct WhoamiInfo { + pub identity_key: String, + pub fingerprint: String, + pub hybrid_key: bool, + pub has_group: bool, +} + +#[derive(Debug, Clone)] +pub struct HealthInfo { + pub status: String, + pub rtt_ms: u64, +} + +#[derive(Debug, Clone)] +pub struct ReceivedMsg { + pub plaintext: String, +} + +// ── Client ─────────────────────────────────────────────────────────────────── + +/// A configured client handle. +/// +/// # Notes +/// - One tokio runtime is created per `Client` instance. +/// - State is loaded from / persisted to `state_path` on every call. +/// - `access_token` must be a valid bearer token accepted by the server. +/// After calling `login()` you should construct a new `Client` with the +/// returned session token as the `access_token`. +/// - Each method opens a new QUIC connection (same behaviour as the CLI). +pub struct Client { + server: String, + ca_cert: PathBuf, + server_name: String, + state_path: PathBuf, + state_password: Option, + access_token: Vec, + device_id: Vec, + rt: tokio::runtime::Runtime, +} + +impl Client { + pub fn new( + server: impl Into, + ca_cert: impl AsRef, + server_name: impl Into, + state_path: impl AsRef, + access_token: impl Into, + state_password: Option, + device_id: Option, + ) -> anyhow::Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("build tokio runtime")?; + Ok(Self { + server: server.into(), + ca_cert: ca_cert.as_ref().to_path_buf(), + server_name: server_name.into(), + state_path: state_path.as_ref().to_path_buf(), + access_token: access_token.into().into_bytes(), + device_id: device_id.unwrap_or_default().into_bytes(), + state_password, + rt, + }) + } + + /// Run an async future inside a `LocalSet` (required for `capnp-rpc`'s `!Send` types). + fn run(&self, fut: F) -> anyhow::Result + where + F: std::future::Future>, + { + let local = tokio::task::LocalSet::new(); + self.rt.block_on(local.run_until(fut)) + } + + // ── Identity ───────────────────────────────────────────────────────────── + + /// Return local identity information from the state file (no network call). + pub fn whoami(&self) -> anyhow::Result { + let state = load_existing_state(&self.state_path, self.state_password.as_deref())?; + let identity = IdentityKeypair::from_seed(state.identity_seed); + let pk = identity.public_key_bytes(); + let fp = sha256(&pk); + Ok(WhoamiInfo { + identity_key: hex::encode(pk), + fingerprint: hex::encode(fp), + hybrid_key: state.hybrid_key.is_some(), + has_group: state.group.is_some(), + }) + } + + // ── Server connectivity ─────────────────────────────────────────────────── + + /// Probe server health. Returns status string and round-trip time in ms. + pub fn health(&self) -> anyhow::Result { + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + self.run(async move { + let sent_at = current_timestamp_ms(); + let node = connect_node(&server, &ca_cert, &server_name).await?; + let req = node.health_request(); + let resp = req.send().promise.await.context("health RPC failed")?; + let status = resp + .get() + .context("health: bad response")? + .get_status() + .context("health: missing status")? + .to_str() + .unwrap_or("unknown") + .to_string(); + let rtt_ms = current_timestamp_ms().saturating_sub(sent_at); + Ok(HealthInfo { status, rtt_ms }) + }) + } + + // ── OPAQUE authentication ───────────────────────────────────────────────── + + /// Register a new user account via OPAQUE. + pub fn register_user(&self, username: &str, password: &str) -> anyhow::Result<()> { + let username = username.to_string(); + let password = password.to_string(); + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + self.run(async move { + let mut rng = rand::rngs::OsRng; + let node = connect_node(&server, &ca_cert, &server_name).await?; + + let reg_start = ClientRegistration::::start(&mut rng, password.as_bytes()) + .map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?; + + let mut req = node.opaque_register_start_request(); + { + let mut p = req.get(); + p.set_username(&username); + p.set_request(®_start.message.serialize()); + } + let resp = req + .send() + .promise + .await + .context("opaque_register_start RPC failed")?; + let response_bytes = resp + .get() + .context("register_start: bad response")? + .get_response() + .context("register_start: missing response")? + .to_vec(); + + let reg_response = RegistrationResponse::::deserialize(&response_bytes) + .map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?; + + let reg_finish = reg_start + .state + .finish( + &mut rng, + password.as_bytes(), + reg_response, + ClientRegistrationFinishParameters::::default(), + ) + .map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?; + + let mut req = node.opaque_register_finish_request(); + { + let mut p = req.get(); + p.set_username(&username); + p.set_upload(®_finish.message.serialize()); + p.set_identity_key(&[]); + } + let resp = req + .send() + .promise + .await + .context("opaque_register_finish RPC failed")?; + let success = resp + .get() + .context("register_finish: bad response")? + .get_success(); + anyhow::ensure!(success, "server rejected registration"); + Ok(()) + }) + } + + /// Log in via OPAQUE. Returns the session token as a hex string. + /// + /// Construct a new `Client` with this token as `access_token` for + /// authenticated operations. + pub fn login(&self, username: &str, password: &str) -> anyhow::Result { + let username = username.to_string(); + let password = password.to_string(); + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + self.run(async move { + let mut rng = rand::rngs::OsRng; + let node = connect_node(&server, &ca_cert, &server_name).await?; + + let login_start = ClientLogin::::start(&mut rng, password.as_bytes()) + .map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?; + + let mut req = node.opaque_login_start_request(); + { + let mut p = req.get(); + p.set_username(&username); + p.set_request(&login_start.message.serialize()); + } + let resp = req + .send() + .promise + .await + .context("opaque_login_start RPC failed")?; + let response_bytes = resp + .get() + .context("login_start: bad response")? + .get_response() + .context("login_start: missing response")? + .to_vec(); + + let credential_response = + CredentialResponse::::deserialize(&response_bytes) + .map_err(|e| anyhow::anyhow!("invalid credential response: {e}"))?; + + let login_finish = login_start + .state + .finish( + &mut rng, + password.as_bytes(), + credential_response, + ClientLoginFinishParameters::::default(), + ) + .map_err(|e| anyhow::anyhow!("OPAQUE login finish (bad password?): {e}"))?; + + // Derive identity key from local state file. + let state = load_existing_state(&state_path, state_password.as_deref()) + .context("load state to get identity key for login")?; + let identity = IdentityKeypair::from_seed(state.identity_seed); + let identity_key = identity.public_key_bytes().to_vec(); + + let mut req = node.opaque_login_finish_request(); + { + let mut p = req.get(); + p.set_username(&username); + p.set_finalization(&login_finish.message.serialize()); + p.set_identity_key(&identity_key); + } + let resp = req + .send() + .promise + .await + .context("opaque_login_finish RPC failed")?; + let token = resp + .get() + .context("login_finish: bad response")? + .get_session_token() + .context("login_finish: missing session token")? + .to_vec(); + anyhow::ensure!(!token.is_empty(), "server returned empty session token"); + Ok(hex::encode(token)) + }) + } + + // ── Key management ──────────────────────────────────────────────────────── + + /// Upload the stored identity's KeyPackage and hybrid key. Returns the + /// fingerprint as a hex string. + pub fn register_state(&self) -> anyhow::Result { + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + let token = self.access_token.clone(); + let device = self.device_id.clone(); + self.run(async move { + let state = load_or_init_state(&state_path, state_password.as_deref())?; + let (mut member, hybrid_kp) = state.into_parts(&state_path)?; + let tls_bytes = member + .generate_key_package() + .context("KeyPackage generation failed")?; + let fingerprint = sha256(&tls_bytes); + let node = connect_node(&server, &ca_cert, &server_name).await?; + upload_key_package( + &node, + &member.identity().public_key_bytes(), + &tls_bytes, + &token, + &device, + ) + .await?; + if let Some(ref hkp) = hybrid_kp { + upload_hybrid_key( + &node, + &member.identity().public_key_bytes(), + &hkp.public_key(), + &token, + &device, + ) + .await?; + } + save_state(&state_path, &member, hybrid_kp.as_ref(), state_password.as_deref())?; + Ok(hex::encode(fingerprint)) + }) + } + + /// Returns `true` if the given peer has uploaded a hybrid public key. + pub fn check_key(&self, peer_hex: &str) -> anyhow::Result { + let peer_key = decode_identity_key(peer_hex)?; + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let token = self.access_token.clone(); + let device = self.device_id.clone(); + self.run(async move { + let node = connect_node(&server, &ca_cert, &server_name).await?; + let pk = fetch_hybrid_key(&node, &peer_key, &token, &device).await?; + Ok(pk.is_some()) + }) + } + + // ── MLS group operations ────────────────────────────────────────────────── + + /// Create a new MLS group and persist state. + pub fn create_group(&self, group_id: &str) -> anyhow::Result<()> { + let group_id = group_id.to_string(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + self.run(async move { + let state = load_or_init_state(&state_path, state_password.as_deref())?; + let (mut member, hybrid_kp) = state.into_parts(&state_path)?; + anyhow::ensure!(member.group_ref().is_none(), "group already exists in state"); + member + .create_group(group_id.as_bytes()) + .context("create_group failed")?; + save_state(&state_path, &member, hybrid_kp.as_ref(), state_password.as_deref())?; + Ok(()) + }) + } + + /// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome. + pub fn invite(&self, peer_hex: &str) -> anyhow::Result<()> { + let peer_key = decode_identity_key(peer_hex)?; + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + let token = self.access_token.clone(); + let device = self.device_id.clone(); + self.run(async move { + let state = load_existing_state(&state_path, state_password.as_deref())?; + let (mut member, hybrid_kp) = state.into_parts(&state_path)?; + let _ = member + .group_ref() + .context("no active group; call create_group first")?; + + let node = connect_node(&server, &ca_cert, &server_name).await?; + + let existing_members: Vec> = member + .member_identities() + .into_iter() + .filter(|k| k.as_slice() != member.identity().public_key_bytes()) + .collect(); + + let peer_kp = fetch_key_package(&node, &peer_key, &token, &device).await?; + anyhow::ensure!(!peer_kp.is_empty(), "server returned empty KeyPackage for peer"); + + let (commit, welcome) = member.add_member(&peer_kp).context("add_member failed")?; + + // Forward commit to existing members. + for mk in &existing_members { + if mk.as_slice() == peer_key.as_slice() { + continue; + } + let peer_hpk = fetch_hybrid_key(&node, mk, &token, &device).await?; + let payload = if let Some(ref pk) = peer_hpk { + hybrid_encrypt(pk, &commit).context("hybrid encrypt commit")? + } else { + commit.clone() + }; + enqueue(&node, mk, &payload, &token, &device).await?; + } + + // Enqueue Welcome for new peer (hybrid-encrypted if they support it). + let peer_hybrid_pk = fetch_hybrid_key(&node, &peer_key, &token, &device).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &welcome).context("hybrid encrypt welcome")? + } else { + welcome + }; + enqueue(&node, &peer_key, &payload, &token, &device).await?; + + save_state(&state_path, &member, hybrid_kp.as_ref(), state_password.as_deref())?; + Ok(()) + }) + } + + /// Join a group by consuming the first Welcome from the server queue. + pub fn join(&self) -> anyhow::Result<()> { + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + let token = self.access_token.clone(); + let device = self.device_id.clone(); + self.run(async move { + let state = load_existing_state(&state_path, state_password.as_deref())?; + let (mut member, hybrid_kp) = state.into_parts(&state_path)?; + anyhow::ensure!(member.group_ref().is_none(), "group already active in state"); + let node = connect_node(&server, &ca_cert, &server_name).await?; + let welcomes = + fetch_all(&node, &member.identity().public_key_bytes(), &token, &device).await?; + let raw = welcomes + .first() + .cloned() + .context("no Welcome found in queue for this identity")?; + let welcome_bytes = + try_hybrid_decrypt(hybrid_kp.as_ref(), &raw).context("decrypt Welcome failed")?; + member + .join_group(&welcome_bytes) + .context("join_group failed")?; + save_state(&state_path, &member, hybrid_kp.as_ref(), state_password.as_deref())?; + Ok(()) + }) + } + + // ── Messaging ───────────────────────────────────────────────────────────── + + /// Send a plaintext message to a peer via the Delivery Service. + pub fn send_message(&self, peer_hex: &str, text: &str) -> anyhow::Result<()> { + let peer_key = decode_identity_key(peer_hex)?; + let text = text.to_string(); + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + let token = self.access_token.clone(); + let device = self.device_id.clone(); + self.run(async move { + let state = load_existing_state(&state_path, state_password.as_deref())?; + let (mut member, hybrid_kp) = state.into_parts(&state_path)?; + let node = connect_node(&server, &ca_cert, &server_name).await?; + let ct = member + .send_message(text.as_bytes()) + .context("send_message failed")?; + let peer_hybrid_pk = fetch_hybrid_key(&node, &peer_key, &token, &device).await?; + let payload = if let Some(ref pk) = peer_hybrid_pk { + hybrid_encrypt(pk, &ct).context("hybrid encrypt failed")? + } else { + ct + }; + enqueue(&node, &peer_key, &payload, &token, &device).await?; + save_state(&state_path, &member, hybrid_kp.as_ref(), state_password.as_deref())?; + Ok(()) + }) + } + + /// Poll for incoming messages. `wait_ms = 0` returns immediately; + /// `wait_ms > 0` long-polls up to that many milliseconds. + pub fn recv(&self, wait_ms: u64) -> anyhow::Result> { + let server = self.server.clone(); + let ca_cert = self.ca_cert.clone(); + let server_name = self.server_name.clone(); + let state_path = self.state_path.clone(); + let state_password = self.state_password.clone(); + let token = self.access_token.clone(); + let device = self.device_id.clone(); + self.run(async move { + let state = load_existing_state(&state_path, state_password.as_deref())?; + let (mut member, hybrid_kp) = state.into_parts(&state_path)?; + let node = connect_node(&server, &ca_cert, &server_name).await?; + let payloads = fetch_wait( + &node, + &member.identity().public_key_bytes(), + wait_ms, + &token, + &device, + ) + .await?; + + let mut msgs = Vec::new(); + for payload in &payloads { + let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) { + Ok(b) => b, + Err(_) => continue, + }; + match member.receive_message(&mls_payload) { + Ok(Some(pt)) => msgs.push(ReceivedMsg { + plaintext: String::from_utf8_lossy(&pt).into_owned(), + }), + Ok(None) => {} // MLS commit — no application message + Err(_) => {} + } + } + if !payloads.is_empty() { + save_state(&state_path, &member, hybrid_kp.as_ref(), state_password.as_deref())?; + } + Ok(msgs) + }) + } +} diff --git a/crates/quicnprotochat-bindings/src/rpc.rs b/crates/quicnprotochat-bindings/src/rpc.rs new file mode 100644 index 0000000..9e7f385 --- /dev/null +++ b/crates/quicnprotochat-bindings/src/rpc.rs @@ -0,0 +1,282 @@ +/// QUIC/Cap'n Proto connection helpers — same logic as quicnprotochat-client/src/client/rpc.rs +/// but with explicit per-call auth instead of a process-global OnceLock. +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Context; +use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem}; +use quinn::{ClientConfig, Endpoint}; +use quinn_proto::crypto::rustls::QuicClientConfig; +use rustls::{pki_types::CertificateDer, ClientConfig as RustlsClientConfig, RootCertStore}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use quicnprotochat_core::HybridPublicKey; +use quicnprotochat_proto::node_capnp::{auth, node_service}; + +/// Establish a QUIC/TLS connection and return a `NodeService` RPC client. +/// +/// Must be called inside a `LocalSet` because `capnp-rpc` is `!Send`. +pub async fn connect_node( + server: &str, + ca_cert: &Path, + server_name: &str, +) -> anyhow::Result { + let addr: SocketAddr = server + .parse() + .with_context(|| format!("server must be host:port, got {server}"))?; + + let cert_bytes = std::fs::read(ca_cert) + .with_context(|| format!("read ca_cert {ca_cert:?}"))?; + let mut roots = RootCertStore::empty(); + roots + .add(CertificateDer::from(cert_bytes)) + .context("add root cert")?; + + let mut tls = RustlsClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + tls.alpn_protocols = vec![b"capnp".to_vec()]; + + let crypto = QuicClientConfig::try_from(tls) + .map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?; + + let bind_addr: SocketAddr = "0.0.0.0:0".parse().context("parse client bind address")?; + let mut endpoint = Endpoint::client(bind_addr)?; + endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto))); + + let connection = endpoint + .connect(addr, server_name) + .context("quic connect init")? + .await + .context("quic connect failed")?; + + let (send, recv) = connection.open_bi().await.context("open bi stream")?; + + let network = twoparty::VatNetwork::new( + recv.compat(), + send.compat_write(), + Side::Client, + Default::default(), + ); + + let mut rpc_system = RpcSystem::new(Box::new(network), None); + let client: node_service::Client = rpc_system.bootstrap(Side::Server); + tokio::task::spawn_local(rpc_system); + + Ok(client) +} + +/// Populate an auth field from explicit token/device bytes (no global state). +pub fn set_auth(auth: &mut auth::Builder<'_>, token: &[u8], device: &[u8]) { + auth.set_version(1); + auth.set_access_token(token); + auth.set_device_id(device); +} + +pub async fn upload_key_package( + client: &node_service::Client, + identity_key: &[u8], + package: &[u8], + token: &[u8], + device: &[u8], +) -> anyhow::Result<()> { + let mut req = client.upload_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + p.set_package(package); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + let resp = req + .send() + .promise + .await + .context("upload_key_package RPC failed")?; + let server_fp = resp + .get() + .context("upload_key_package: bad response")? + .get_fingerprint() + .context("upload_key_package: missing fingerprint")? + .to_vec(); + let local_fp = super::state::sha256(package); + anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch"); + Ok(()) +} + +pub async fn fetch_key_package( + client: &node_service::Client, + identity_key: &[u8], + token: &[u8], + device: &[u8], +) -> anyhow::Result> { + let mut req = client.fetch_key_package_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + let resp = req + .send() + .promise + .await + .context("fetch_key_package RPC failed")?; + Ok(resp + .get() + .context("fetch_key_package: bad response")? + .get_package() + .context("fetch_key_package: missing package")? + .to_vec()) +} + +pub async fn enqueue( + client: &node_service::Client, + recipient_key: &[u8], + payload: &[u8], + token: &[u8], + device: &[u8], +) -> anyhow::Result<()> { + let mut req = client.enqueue_request(); + { + let mut p = req.get(); + p.set_recipient_key(recipient_key); + p.set_payload(payload); + p.set_channel_id(&[]); + p.set_version(1); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + req.send().promise.await.context("enqueue RPC failed")?; + Ok(()) +} + +pub async fn fetch_all( + client: &node_service::Client, + recipient_key: &[u8], + token: &[u8], + device: &[u8], +) -> anyhow::Result>> { + let mut req = client.fetch_request(); + { + let mut p = req.get(); + p.set_recipient_key(recipient_key); + p.set_channel_id(&[]); + p.set_version(1); + p.set_limit(0); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + let resp = req.send().promise.await.context("fetch RPC failed")?; + let list = resp + .get() + .context("fetch: bad response")? + .get_payloads() + .context("fetch: missing payloads")?; + let mut out = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + out.push(list.get(i).context("fetch: payload read error")?.to_vec()); + } + Ok(out) +} + +pub async fn fetch_wait( + client: &node_service::Client, + recipient_key: &[u8], + timeout_ms: u64, + token: &[u8], + device: &[u8], +) -> anyhow::Result>> { + let mut req = client.fetch_wait_request(); + { + let mut p = req.get(); + p.set_recipient_key(recipient_key); + p.set_timeout_ms(timeout_ms); + p.set_channel_id(&[]); + p.set_version(1); + p.set_limit(0); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + let resp = req.send().promise.await.context("fetch_wait RPC failed")?; + let list = resp + .get() + .context("fetch_wait: bad response")? + .get_payloads() + .context("fetch_wait: missing payloads")?; + let mut out = Vec::with_capacity(list.len() as usize); + for i in 0..list.len() { + out.push(list.get(i).context("fetch_wait: payload read error")?.to_vec()); + } + Ok(out) +} + +pub async fn upload_hybrid_key( + client: &node_service::Client, + identity_key: &[u8], + hybrid_pk: &HybridPublicKey, + token: &[u8], + device: &[u8], +) -> anyhow::Result<()> { + let mut req = client.upload_hybrid_key_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + p.set_hybrid_public_key(&hybrid_pk.to_bytes()); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + req.send() + .promise + .await + .context("upload_hybrid_key RPC failed")?; + Ok(()) +} + +pub async fn fetch_hybrid_key( + client: &node_service::Client, + identity_key: &[u8], + token: &[u8], + device: &[u8], +) -> anyhow::Result> { + let mut req = client.fetch_hybrid_key_request(); + { + let mut p = req.get(); + p.set_identity_key(identity_key); + let mut auth = p.reborrow().init_auth(); + set_auth(&mut auth, token, device); + } + let resp = req + .send() + .promise + .await + .context("fetch_hybrid_key RPC failed")?; + let pk_bytes = resp + .get() + .context("fetch_hybrid_key: bad response")? + .get_hybrid_public_key() + .context("fetch_hybrid_key: missing field")? + .to_vec(); + if pk_bytes.is_empty() { + return Ok(None); + } + let pk = HybridPublicKey::from_bytes(&pk_bytes).context("invalid hybrid public key")?; + Ok(Some(pk)) +} + +/// Attempt hybrid decryption; returns the inner payload on success. +pub fn try_hybrid_decrypt( + hybrid_kp: Option<&quicnprotochat_core::HybridKeypair>, + payload: &[u8], +) -> anyhow::Result> { + let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?; + quicnprotochat_core::hybrid_decrypt(kp, payload).map_err(|e| anyhow::anyhow!("{e}")) +} + +pub fn current_timestamp_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} diff --git a/crates/quicnprotochat-bindings/src/state.rs b/crates/quicnprotochat-bindings/src/state.rs new file mode 100644 index 0000000..8336f95 --- /dev/null +++ b/crates/quicnprotochat-bindings/src/state.rs @@ -0,0 +1,184 @@ +/// Client state management — mirrors the QPCE format used by quicnprotochat-client +/// so that state files are fully interoperable between the CLI and language bindings. +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context; +use argon2::Argon2; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; + +use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair}; + +const STATE_MAGIC: &[u8; 4] = b"QPCE"; +const STATE_SALT_LEN: usize = 16; +const STATE_NONCE_LEN: usize = 12; + +/// Serialised client state — identical schema to the CLI's StoredState. +#[derive(Serialize, Deserialize)] +pub struct StoredState { + pub identity_seed: [u8; 32], + pub group: Option>, + #[serde(default)] + pub hybrid_key: Option, + #[serde(default)] + pub member_keys: Vec>, +} + +impl StoredState { + pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option)> { + let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed)); + let group = self + .group + .map(|bytes| bincode::deserialize(&bytes).context("decode group")) + .transpose()?; + let key_store = DiskKeyStore::persistent(keystore_path(state_path))?; + let member = GroupMember::new_with_state(identity, key_store, group); + let hybrid_kp = self + .hybrid_key + .map(|bytes| HybridKeypair::from_bytes(&bytes).context("decode hybrid key")) + .transpose()?; + Ok((member, hybrid_kp)) + } + + pub fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result { + let group = member + .group_ref() + .map(|g| bincode::serialize(g).context("serialize group")) + .transpose()?; + Ok(Self { + identity_seed: member.identity_seed(), + group, + hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()), + member_keys: Vec::new(), + }) + } +} + +fn derive_state_key(password: &str, salt: &[u8]) -> anyhow::Result<[u8; 32]> { + let mut key = [0u8; 32]; + Argon2::default() + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|e| anyhow::anyhow!("argon2 key derivation failed: {e}"))?; + Ok(key) +} + +pub fn encrypt_state(password: &str, plaintext: &[u8]) -> anyhow::Result> { + 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| anyhow::anyhow!("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) +} + +pub fn decrypt_state(password: &str, data: &[u8]) -> anyhow::Result> { + let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN; + anyhow::ensure!( + data.len() > header_len, + "encrypted state file too short ({} bytes)", + data.len() + ); + 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(|_| anyhow::anyhow!("state decryption failed (wrong password?)")) +} + +fn is_encrypted(bytes: &[u8]) -> bool { + bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC +} + +pub fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result { + if path.exists() { + let mut state = load_existing_state(path, password)?; + if state.hybrid_key.is_none() { + state.hybrid_key = Some(HybridKeypair::generate().to_bytes()); + write_state(path, &state, password)?; + } + return Ok(state); + } + + let identity = IdentityKeypair::generate(); + let hybrid_kp = HybridKeypair::generate(); + let key_store = DiskKeyStore::persistent(keystore_path(path))?; + let member = GroupMember::new_with_state(Arc::new(identity), key_store, None); + let state = StoredState::from_parts(&member, Some(&hybrid_kp))?; + write_state(path, &state, password)?; + Ok(state) +} + +pub fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Result { + let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?; + if is_encrypted(&bytes) { + let pw = password.context("state file is encrypted (QPCE); a password is required")?; + let plaintext = decrypt_state(pw, &bytes)?; + bincode::deserialize(&plaintext).context("decode encrypted state") + } else { + bincode::deserialize(&bytes).context("decode state") + } +} + +pub fn save_state( + path: &Path, + member: &GroupMember, + hybrid_kp: Option<&HybridKeypair>, + password: Option<&str>, +) -> anyhow::Result<()> { + let state = StoredState::from_parts(member, hybrid_kp)?; + write_state(path, &state, password) +} + +pub fn write_state(path: &Path, state: &StoredState, password: Option<&str>) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?; + } + let plaintext = bincode::serialize(state).context("encode state")?; + let bytes = if let Some(pw) = password { + encrypt_state(pw, &plaintext)? + } else { + plaintext + }; + std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?; + Ok(()) +} + +pub fn keystore_path(state_path: &Path) -> PathBuf { + let mut path = state_path.to_path_buf(); + path.set_extension("ks"); + path +} + +pub fn sha256(bytes: &[u8]) -> Vec { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes).to_vec() +} + +pub fn decode_identity_key(hex_str: &str) -> anyhow::Result> { + let bytes = hex::decode(hex_str.trim()) + .map_err(|e| anyhow::anyhow!("identity key must be hex: {e}"))?; + anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes, got {}", bytes.len()); + Ok(bytes) +} diff --git a/crates/quicnprotochat-python/Cargo.toml b/crates/quicnprotochat-python/Cargo.toml new file mode 100644 index 0000000..0c76984 --- /dev/null +++ b/crates/quicnprotochat-python/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "quicnprotochat-python" +version = "0.1.0" +edition = "2021" +description = "Python bindings for quicnprotochat (PyO3 / maturin)." +license = "MIT" + +[lib] +name = "quicnprotochat" +crate-type = ["cdylib"] + +[dependencies] +quicnprotochat-bindings = { path = "../quicnprotochat-bindings" } +pyo3 = { version = "0.22", features = ["extension-module"] } diff --git a/crates/quicnprotochat-python/pyproject.toml b/crates/quicnprotochat-python/pyproject.toml new file mode 100644 index 0000000..be9bf0d --- /dev/null +++ b/crates/quicnprotochat-python/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "quicnprotochat" +version = "0.1.0" +description = "Python bindings for quicnprotochat — QUIC + MLS + post-quantum E2E chat." +license = { text = "MIT" } +requires-python = ">=3.8" +readme = "README.md" + +[tool.maturin] +# The Cargo manifest is one level up in the workspace; point maturin at it. +manifest-path = "Cargo.toml" +module-name = "quicnprotochat" +features = ["pyo3/extension-module"] diff --git a/crates/quicnprotochat-python/src/lib.rs b/crates/quicnprotochat-python/src/lib.rs new file mode 100644 index 0000000..aa236bb --- /dev/null +++ b/crates/quicnprotochat-python/src/lib.rs @@ -0,0 +1,176 @@ +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use quicnprotochat_bindings::Client; + +fn to_py(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(e.to_string()) +} + +// ── Return types as Python classes ─────────────────────────────────────────── + +#[pyclass(get_all)] +struct WhoamiInfo { + identity_key: String, + fingerprint: String, + hybrid_key: bool, + has_group: bool, +} + +#[pyclass(get_all)] +struct HealthInfo { + status: String, + rtt_ms: u64, +} + +#[pyclass(get_all)] +struct ReceivedMsg { + plaintext: String, +} + +// ── Client ──────────────────────────────────────────────────────────────────── + +#[pyclass(name = "Client")] +struct PyClient { + inner: Client, +} + +#[pymethods] +impl PyClient { + /// Create a new client. + /// + /// Args: + /// server: "host:port" of the quicnprotochat server. + /// ca_cert: Path to the server's TLS certificate (DER format). + /// server_name: TLS SNI name (must match the certificate). + /// state_path: Path where client state is stored. + /// access_token: Bearer token for server authentication. + /// state_password: Optional password to decrypt/encrypt the state file. + /// device_id: Optional device identifier included in auth headers. + #[new] + #[pyo3(signature = (server, ca_cert, server_name, state_path, access_token, + state_password=None, device_id=None))] + fn new( + server: &str, + ca_cert: &str, + server_name: &str, + state_path: &str, + access_token: &str, + state_password: Option, + device_id: Option, + ) -> PyResult { + let inner = Client::new( + server, + ca_cert, + server_name, + state_path, + access_token, + state_password, + device_id, + ) + .map_err(to_py)?; + Ok(PyClient { inner }) + } + + /// Return local identity info from the state file (no network call). + fn whoami(&self, py: Python<'_>) -> PyResult { + let info = py.allow_threads(|| self.inner.whoami()).map_err(to_py)?; + Ok(WhoamiInfo { + identity_key: info.identity_key, + fingerprint: info.fingerprint, + hybrid_key: info.hybrid_key, + has_group: info.has_group, + }) + } + + /// Probe server health. Returns HealthInfo with `status` and `rtt_ms`. + fn health(&self, py: Python<'_>) -> PyResult { + let info = py.allow_threads(|| self.inner.health()).map_err(to_py)?; + Ok(HealthInfo { + status: info.status, + rtt_ms: info.rtt_ms, + }) + } + + /// Register a new user account via OPAQUE password authentication. + fn register_user(&self, py: Python<'_>, username: &str, password: &str) -> PyResult<()> { + let (u, p) = (username.to_string(), password.to_string()); + py.allow_threads(|| self.inner.register_user(&u, &p)) + .map_err(to_py) + } + + /// Log in via OPAQUE. Returns the session token as a hex string. + /// + /// Construct a new Client with this token as `access_token` for + /// subsequent authenticated calls. + fn login(&self, py: Python<'_>, username: &str, password: &str) -> PyResult { + let (u, p) = (username.to_string(), password.to_string()); + py.allow_threads(|| self.inner.login(&u, &p)) + .map_err(to_py) + } + + /// Upload the stored identity's KeyPackage and hybrid key to the server. + /// Returns the KeyPackage fingerprint as a hex string. + fn register_state(&self, py: Python<'_>) -> PyResult { + py.allow_threads(|| self.inner.register_state()) + .map_err(to_py) + } + + /// Returns True if the peer has uploaded a hybrid public key. + fn check_key(&self, py: Python<'_>, peer_hex: &str) -> PyResult { + let k = peer_hex.to_string(); + py.allow_threads(|| self.inner.check_key(&k)) + .map_err(to_py) + } + + /// Create a new MLS group and persist state. + fn create_group(&self, py: Python<'_>, group_id: &str) -> PyResult<()> { + let g = group_id.to_string(); + py.allow_threads(|| self.inner.create_group(&g)) + .map_err(to_py) + } + + /// Invite a peer into the current group (fetches their KeyPackage, + /// adds them, and enqueues a hybrid-encrypted Welcome). + fn invite(&self, py: Python<'_>, peer_hex: &str) -> PyResult<()> { + let k = peer_hex.to_string(); + py.allow_threads(|| self.inner.invite(&k)).map_err(to_py) + } + + /// Join a group by consuming a Welcome from the server queue. + fn join(&self, py: Python<'_>) -> PyResult<()> { + py.allow_threads(|| self.inner.join()).map_err(to_py) + } + + /// Encrypt and send `text` to `peer_hex` via the Delivery Service. + fn send_message(&self, py: Python<'_>, peer_hex: &str, text: &str) -> PyResult<()> { + let (k, t) = (peer_hex.to_string(), text.to_string()); + py.allow_threads(|| self.inner.send_message(&k, &t)) + .map_err(to_py) + } + + /// Poll for incoming messages. Returns a list of ReceivedMsg objects. + /// + /// Args: + /// wait_ms: Long-poll timeout in milliseconds (0 = return immediately). + #[pyo3(signature = (wait_ms = 0))] + fn recv(&self, py: Python<'_>, wait_ms: u64) -> PyResult> { + let msgs = py + .allow_threads(|| self.inner.recv(wait_ms)) + .map_err(to_py)?; + Ok(msgs + .into_iter() + .map(|m| ReceivedMsg { plaintext: m.plaintext }) + .collect()) + } +} + +// ── Module ──────────────────────────────────────────────────────────────────── + +#[pymodule] +fn quicnprotochat(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} diff --git a/crates/quicnprotochat-ruby/Cargo.toml b/crates/quicnprotochat-ruby/Cargo.toml new file mode 100644 index 0000000..4425e3c --- /dev/null +++ b/crates/quicnprotochat-ruby/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "quicnprotochat-ruby" +version = "0.1.0" +edition = "2021" +description = "Ruby bindings for quicnprotochat (Magnus)." +license = "MIT" + +[lib] +name = "quicnprotochat_ruby" +crate-type = ["cdylib"] + +[dependencies] +quicnprotochat-bindings = { path = "../quicnprotochat-bindings" } +magnus = { version = "0.7" } diff --git a/crates/quicnprotochat-ruby/Rakefile b/crates/quicnprotochat-ruby/Rakefile new file mode 100644 index 0000000..c6b0e41 --- /dev/null +++ b/crates/quicnprotochat-ruby/Rakefile @@ -0,0 +1,39 @@ +require "fileutils" + +WORKSPACE_ROOT = File.expand_path("../..", __dir__) +TARGET_DIR = File.join(WORKSPACE_ROOT, "target") +LIB_DIR = File.join(__dir__, "lib") + +# Detect the shared library extension for the current platform. +SO_EXT = case RUBY_PLATFORM + when /darwin/ then "dylib" + when /mingw|mswin/ then "dll" + else "so" + end + +LIB_SRC = File.join(TARGET_DIR, "release", "libquicnprotochat_ruby.#{SO_EXT}") +LIB_DEST = File.join(LIB_DIR, "quicnprotochat_ruby.#{SO_EXT}") + +desc "Build the native extension (release)" +task :build do + sh "cargo build --release --manifest-path #{File.join(__dir__, "Cargo.toml")}" + FileUtils.mkdir_p(LIB_DIR) + FileUtils.cp(LIB_SRC, LIB_DEST) + puts "Copied #{LIB_DEST}" +end + +desc "Build the native extension (debug)" +task :build_dev do + sh "cargo build --manifest-path #{File.join(__dir__, "Cargo.toml")}" + lib_src = File.join(TARGET_DIR, "debug", "libquicnprotochat_ruby.#{SO_EXT}") + FileUtils.mkdir_p(LIB_DIR) + FileUtils.cp(lib_src, LIB_DEST) + puts "Copied #{LIB_DEST} (debug)" +end + +desc "Remove build artefacts" +task :clean do + FileUtils.rm_f(LIB_DEST) +end + +task default: :build diff --git a/crates/quicnprotochat-ruby/src/lib.rs b/crates/quicnprotochat-ruby/src/lib.rs new file mode 100644 index 0000000..2d95f7d --- /dev/null +++ b/crates/quicnprotochat-ruby/src/lib.rs @@ -0,0 +1,170 @@ +use magnus::{ + class, define_module, exception, function, method, + prelude::*, + Error, Ruby, +}; +use quicnprotochat_bindings::Client; +use std::sync::{Arc, Mutex}; + +fn to_rb(e: anyhow::Error) -> Error { + Error::new(exception::runtime_error(), e.to_string()) +} + +// ── RbClient ────────────────────────────────────────────────────────────────── + +/// Ruby wrapper around `Client`. Wrapped in `Arc>` so that Ruby's GC +/// can safely reference-count it while the GVL serialises all calls. +#[magnus::wrap(class = "QuicNProtoChat::Client", free_immediately, size)] +struct RbClient(Arc>); + +impl RbClient { + fn new( + ruby: &Ruby, + server: String, + ca_cert: String, + server_name: String, + state_path: String, + access_token: String, + state_password: Option, + device_id: Option, + ) -> Result { + let inner = Client::new( + &server, + &ca_cert, + &server_name, + &state_path, + &access_token, + state_password, + device_id, + ) + .map_err(to_rb)?; + Ok(RbClient(Arc::new(Mutex::new(inner)))) + } + + fn whoami(ruby: &Ruby, rb_self: &RbClient) -> Result { + let info = rb_self.0.lock().unwrap().whoami().map_err(to_rb)?; + let h = ruby.hash_new(); + h.aset(ruby.str_new("identity_key"), ruby.str_new(&info.identity_key))?; + h.aset(ruby.str_new("fingerprint"), ruby.str_new(&info.fingerprint))?; + h.aset(ruby.str_new("hybrid_key"), info.hybrid_key)?; + h.aset(ruby.str_new("has_group"), info.has_group)?; + Ok(h) + } + + fn health(ruby: &Ruby, rb_self: &RbClient) -> Result { + let info = rb_self.0.lock().unwrap().health().map_err(to_rb)?; + let h = ruby.hash_new(); + h.aset(ruby.str_new("status"), ruby.str_new(&info.status))?; + h.aset(ruby.str_new("rtt_ms"), info.rtt_ms)?; + Ok(h) + } + + fn register_user( + _ruby: &Ruby, + rb_self: &RbClient, + username: String, + password: String, + ) -> Result<(), Error> { + rb_self + .0 + .lock() + .unwrap() + .register_user(&username, &password) + .map_err(to_rb) + } + + fn login( + _ruby: &Ruby, + rb_self: &RbClient, + username: String, + password: String, + ) -> Result { + rb_self + .0 + .lock() + .unwrap() + .login(&username, &password) + .map_err(to_rb) + } + + fn register_state(_ruby: &Ruby, rb_self: &RbClient) -> Result { + rb_self.0.lock().unwrap().register_state().map_err(to_rb) + } + + fn check_key(_ruby: &Ruby, rb_self: &RbClient, peer_hex: String) -> Result { + rb_self + .0 + .lock() + .unwrap() + .check_key(&peer_hex) + .map_err(to_rb) + } + + fn create_group(_ruby: &Ruby, rb_self: &RbClient, group_id: String) -> Result<(), Error> { + rb_self + .0 + .lock() + .unwrap() + .create_group(&group_id) + .map_err(to_rb) + } + + fn invite(_ruby: &Ruby, rb_self: &RbClient, peer_hex: String) -> Result<(), Error> { + rb_self.0.lock().unwrap().invite(&peer_hex).map_err(to_rb) + } + + fn join(_ruby: &Ruby, rb_self: &RbClient) -> Result<(), Error> { + rb_self.0.lock().unwrap().join().map_err(to_rb) + } + + fn send_message( + _ruby: &Ruby, + rb_self: &RbClient, + peer_hex: String, + text: String, + ) -> Result<(), Error> { + rb_self + .0 + .lock() + .unwrap() + .send_message(&peer_hex, &text) + .map_err(to_rb) + } + + fn recv(ruby: &Ruby, rb_self: &RbClient, wait_ms: u64) -> Result { + let msgs = rb_self.0.lock().unwrap().recv(wait_ms).map_err(to_rb)?; + let arr = ruby.ary_new_capa(msgs.len()); + for m in msgs { + let h = ruby.hash_new(); + h.aset(ruby.str_new("plaintext"), ruby.str_new(&m.plaintext))?; + arr.push(h)?; + } + Ok(arr) + } +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +#[magnus::init] +fn init(ruby: &Ruby) -> Result<(), Error> { + let module = ruby.define_module("QuicNProtoChat")?; + let cls = module.define_class("Client", ruby.class_object())?; + + // QuicNProtoChat::Client.new(server, ca_cert, server_name, state_path, + // access_token, state_password=nil, device_id=nil) + cls.define_singleton_method("new", method!(RbClient::new, 7))?; + + cls.define_method("whoami", method!(RbClient::whoami, 0))?; + cls.define_method("health", method!(RbClient::health, 0))?; + cls.define_method("register_user", method!(RbClient::register_user, 2))?; + cls.define_method("login", method!(RbClient::login, 2))?; + cls.define_method("register_state", method!(RbClient::register_state, 0))?; + cls.define_method("check_key", method!(RbClient::check_key, 1))?; + cls.define_method("create_group", method!(RbClient::create_group, 1))?; + cls.define_method("invite", method!(RbClient::invite, 1))?; + cls.define_method("join", method!(RbClient::join, 0))?; + cls.define_method("send_message", method!(RbClient::send_message, 2))?; + cls.define_method("recv", method!(RbClient::recv, 1))?; + + Ok(()) +}