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:
44
Cargo.toml
Normal file
44
Cargo.toml
Normal file
@@ -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" }
|
||||||
47
crates/quicnprotochat-bindings/Cargo.toml
Normal file
47
crates/quicnprotochat-bindings/Cargo.toml
Normal 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"
|
||||||
536
crates/quicnprotochat-bindings/src/lib.rs
Normal file
536
crates/quicnprotochat-bindings/src/lib.rs
Normal 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(®_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(®_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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
282
crates/quicnprotochat-bindings/src/rpc.rs
Normal file
282
crates/quicnprotochat-bindings/src/rpc.rs
Normal 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
|
||||||
|
}
|
||||||
184
crates/quicnprotochat-bindings/src/state.rs
Normal file
184
crates/quicnprotochat-bindings/src/state.rs
Normal 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)
|
||||||
|
}
|
||||||
14
crates/quicnprotochat-python/Cargo.toml
Normal file
14
crates/quicnprotochat-python/Cargo.toml
Normal file
@@ -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"] }
|
||||||
17
crates/quicnprotochat-python/pyproject.toml
Normal file
17
crates/quicnprotochat-python/pyproject.toml
Normal file
@@ -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"]
|
||||||
176
crates/quicnprotochat-python/src/lib.rs
Normal file
176
crates/quicnprotochat-python/src/lib.rs
Normal file
@@ -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<String>,
|
||||||
|
device_id: Option<String>,
|
||||||
|
) -> PyResult<Self> {
|
||||||
|
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<WhoamiInfo> {
|
||||||
|
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<HealthInfo> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<bool> {
|
||||||
|
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<Vec<ReceivedMsg>> {
|
||||||
|
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::<PyClient>()?;
|
||||||
|
m.add_class::<WhoamiInfo>()?;
|
||||||
|
m.add_class::<HealthInfo>()?;
|
||||||
|
m.add_class::<ReceivedMsg>()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
14
crates/quicnprotochat-ruby/Cargo.toml
Normal file
14
crates/quicnprotochat-ruby/Cargo.toml
Normal file
@@ -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" }
|
||||||
39
crates/quicnprotochat-ruby/Rakefile
Normal file
39
crates/quicnprotochat-ruby/Rakefile
Normal file
@@ -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
|
||||||
170
crates/quicnprotochat-ruby/src/lib.rs
Normal file
170
crates/quicnprotochat-ruby/src/lib.rs
Normal file
@@ -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<Mutex<>>` 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<Mutex<Client>>);
|
||||||
|
|
||||||
|
impl RbClient {
|
||||||
|
fn new(
|
||||||
|
ruby: &Ruby,
|
||||||
|
server: String,
|
||||||
|
ca_cert: String,
|
||||||
|
server_name: String,
|
||||||
|
state_path: String,
|
||||||
|
access_token: String,
|
||||||
|
state_password: Option<String>,
|
||||||
|
device_id: Option<String>,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
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<magnus::RHash, Error> {
|
||||||
|
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<magnus::RHash, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
rb_self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.login(&username, &password)
|
||||||
|
.map_err(to_rb)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_state(_ruby: &Ruby, rb_self: &RbClient) -> Result<String, Error> {
|
||||||
|
rb_self.0.lock().unwrap().register_state().map_err(to_rb)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_key(_ruby: &Ruby, rb_self: &RbClient, peer_hex: String) -> Result<bool, Error> {
|
||||||
|
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<magnus::RArray, Error> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user