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.
This commit is contained in:
2026-02-22 18:56:27 +01:00
commit f511903a5d
11 changed files with 1523 additions and 0 deletions

View File

@@ -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"

View File

@@ -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<String>,
access_token: Vec<u8>,
device_id: Vec<u8>,
rt: tokio::runtime::Runtime,
}
impl Client {
pub fn new(
server: impl Into<String>,
ca_cert: impl AsRef<Path>,
server_name: impl Into<String>,
state_path: impl AsRef<Path>,
access_token: impl Into<String>,
state_password: Option<String>,
device_id: Option<String>,
) -> anyhow::Result<Self> {
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<F, T>(&self, fut: F) -> anyhow::Result<T>
where
F: std::future::Future<Output = anyhow::Result<T>>,
{
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<WhoamiInfo> {
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<HealthInfo> {
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::<OpaqueSuite>::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(&reg_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::<OpaqueSuite>::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::<OpaqueSuite>::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(&reg_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<String> {
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::<OpaqueSuite>::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::<OpaqueSuite>::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::<OpaqueSuite>::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<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_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<bool> {
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<Vec<u8>> = 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<Vec<ReceivedMsg>> {
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)
})
}
}

View File

@@ -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<node_service::Client> {
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<Vec<u8>> {
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<Vec<Vec<u8>>> {
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<Vec<Vec<u8>>> {
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<Option<HybridPublicKey>> {
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<Vec<u8>> {
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
}

View File

@@ -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<Vec<u8>>,
#[serde(default)]
pub hybrid_key: Option<HybridKeypairBytes>,
#[serde(default)]
pub member_keys: Vec<Vec<u8>>,
}
impl StoredState {
pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option<HybridKeypair>)> {
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<Self> {
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<Vec<u8>> {
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<Vec<u8>> {
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<StoredState> {
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<StoredState> {
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<u8> {
use sha2::{Digest, Sha256};
Sha256::digest(bytes).to_vec()
}
pub fn decode_identity_key(hex_str: &str) -> anyhow::Result<Vec<u8>> {
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)
}