Files
Christian Nennemann 2e081ead8e chore: rename quicproquo → quicprochat in docs, Docker, CI, and packaging
Rename all project references from quicproquo/qpq to quicprochat/qpc
across documentation, Docker configuration, CI workflows, packaging
scripts, operational configs, and build tooling.

- Docker: crate paths, binary names, user/group, data dirs, env vars
- CI: workflow crate references, binary names, artifact names
- Docs: all markdown files under docs/, SDK READMEs, book.toml
- Packaging: OpenWrt Makefile, init script, UCI config (file renames)
- Scripts: justfile, dev-shell, screenshot, cross-compile, ai_team
- Operations: Prometheus config, alert rules, Grafana dashboard
- Config: .env.example (QPQ_* → QPC_*), CODEOWNERS paths
- Top-level: README, CONTRIBUTING, ROADMAP, CLAUDE.md
2026-03-21 19:14:06 +01:00

292 lines
11 KiB
Python

"""High-level quicprochat client.
Provides both async (QUIC transport) and sync (FFI transport) APIs for
interacting with a quicprochat server.
"""
from __future__ import annotations
from typing import Optional
from quicprochat.types import (
ConnectOptions,
Envelope,
ChannelResult,
HealthInfo,
ConnectionError,
)
from quicprochat.transport import QuicTransport
from quicprochat.ffi import FfiTransport
from quicprochat import proto, wire
class QpqClient:
"""High-level quicprochat client.
Use ``QpqClient.connect()`` for the async QUIC transport, or
``QpqClient.connect_ffi()`` for the synchronous Rust FFI backend.
Example (async)::
client = await QpqClient.connect(ConnectOptions(addr="127.0.0.1:5001"))
health = await client.health()
token = await client.login_start("alice", opaque_request)
await client.close()
Example (FFI)::
client = QpqClient.connect_ffi(ConnectOptions(addr="127.0.0.1:5001"))
client.ffi_login("alice", "password123")
client.ffi_send("bob", b"hello")
client.close()
"""
def __init__(
self,
quic: Optional[QuicTransport] = None,
ffi: Optional[FfiTransport] = None,
) -> None:
self._quic = quic
self._ffi = ffi
self._session_token: bytes = b""
self._device_id: bytes = b""
# ------------------------------------------------------------------
# Constructors
# ------------------------------------------------------------------
@staticmethod
async def connect(opts: ConnectOptions) -> "QpqClient":
"""Connect to a server using the async QUIC transport."""
transport = await QuicTransport.connect(
opts.addr,
ca_cert_path=opts.ca_cert_path,
server_name=opts.server_name,
insecure_skip_verify=opts.insecure_skip_verify,
connect_timeout_ms=opts.connect_timeout_ms,
request_timeout_ms=opts.request_timeout_ms,
)
return QpqClient(quic=transport)
@staticmethod
def connect_ffi(opts: ConnectOptions) -> "QpqClient":
"""Connect using the synchronous Rust FFI backend."""
transport = FfiTransport.connect(
opts.addr,
ca_cert_path=opts.ca_cert_path,
server_name=opts.server_name,
)
return QpqClient(ffi=transport)
# ------------------------------------------------------------------
# Session management
# ------------------------------------------------------------------
def set_session_token(self, token: bytes) -> None:
"""Set an externally-obtained session token for authenticated RPCs."""
self._session_token = token
def set_device_id(self, device_id: bytes) -> None:
"""Set the device ID for multi-device scoping."""
self._device_id = device_id
# ------------------------------------------------------------------
# Async RPC methods (QUIC transport)
# ------------------------------------------------------------------
async def health(self) -> HealthInfo:
"""Check server health (async)."""
data = await self._rpc(wire.HEALTH, proto.encode_health())
info = proto.decode_health_response(data)
return HealthInfo(
status=str(info["status"]),
node_id=str(info["node_id"]),
version=str(info["version"]),
uptime_secs=int(info["uptime_secs"]),
storage_backend=str(info["storage_backend"]),
)
async def register_start(self, username: str, request: bytes) -> bytes:
"""Start OPAQUE registration. Returns server response bytes."""
payload = proto.encode_opaque_register_start(username, request)
data = await self._rpc(wire.OPAQUE_REGISTER_START, payload)
return proto.decode_opaque_register_start_response(data)
async def register_finish(
self, username: str, upload: bytes, identity_key: bytes
) -> bool:
"""Complete OPAQUE registration."""
payload = proto.encode_opaque_register_finish(username, upload, identity_key)
data = await self._rpc(wire.OPAQUE_REGISTER_FINISH, payload)
return proto.decode_opaque_register_finish_response(data)
async def login_start(self, username: str, request: bytes) -> bytes:
"""Start OPAQUE login. Returns server response bytes."""
payload = proto.encode_opaque_login_start(username, request)
data = await self._rpc(wire.OPAQUE_LOGIN_START, payload)
return proto.decode_opaque_login_start_response(data)
async def login_finish(
self, username: str, finalization: bytes, identity_key: bytes
) -> bytes:
"""Complete OPAQUE login. Returns and stores session token."""
payload = proto.encode_opaque_login_finish(username, finalization, identity_key)
data = await self._rpc(wire.OPAQUE_LOGIN_FINISH, payload)
token = proto.decode_opaque_login_finish_response(data)
self._session_token = token
return token
async def resolve_user(self, username: str) -> tuple[bytes, bytes]:
"""Resolve username to (identity_key, inclusion_proof)."""
payload = proto.encode_resolve_user(username)
data = await self._rpc(wire.RESOLVE_USER, payload)
return proto.decode_resolve_user_response(data)
async def resolve_identity(self, identity_key: bytes) -> str:
"""Resolve identity key to username."""
payload = proto.encode_resolve_identity(identity_key)
data = await self._rpc(wire.RESOLVE_IDENTITY, payload)
return proto.decode_resolve_identity_response(data)
async def create_channel(self, peer_key: bytes) -> ChannelResult:
"""Create a 1:1 DM channel with a peer."""
payload = proto.encode_create_channel(peer_key)
data = await self._rpc(wire.CREATE_CHANNEL, payload)
channel_id, was_new = proto.decode_create_channel_response(data)
return ChannelResult(channel_id=channel_id, was_new=was_new)
async def send(
self,
recipient_key: bytes,
payload: bytes,
*,
channel_id: bytes = b"",
ttl_secs: int = 0,
message_id: bytes = b"",
) -> tuple[int, bytes]:
"""Enqueue a message. Returns (seq, delivery_proof)."""
req = proto.encode_enqueue(recipient_key, payload, channel_id, ttl_secs, message_id)
data = await self._rpc(wire.ENQUEUE, req)
seq, proof, _ = proto.decode_enqueue_response(data)
return seq, proof
async def receive(
self,
recipient_key: bytes,
*,
channel_id: bytes = b"",
limit: int = 0,
device_id: bytes = b"",
) -> list[Envelope]:
"""Fetch queued messages."""
req = proto.encode_fetch(recipient_key, channel_id, limit, device_id)
data = await self._rpc(wire.FETCH, req)
return [Envelope(seq=s, data=d) for s, d in proto.decode_fetch_response(data)]
async def receive_wait(
self,
recipient_key: bytes,
*,
timeout_ms: int = 5000,
channel_id: bytes = b"",
limit: int = 0,
device_id: bytes = b"",
) -> list[Envelope]:
"""Long-poll for messages with a timeout."""
req = proto.encode_fetch_wait(recipient_key, channel_id, timeout_ms, limit, device_id)
data = await self._rpc(wire.FETCH_WAIT, req)
return [Envelope(seq=s, data=d) for s, d in proto.decode_fetch_wait_response(data)]
async def ack(
self,
recipient_key: bytes,
seq_up_to: int,
*,
channel_id: bytes = b"",
device_id: bytes = b"",
) -> None:
"""Acknowledge messages up to a sequence number."""
req = proto.encode_ack(recipient_key, seq_up_to, channel_id, device_id)
await self._rpc(wire.ACK, req)
async def upload_key_package(self, identity_key: bytes, package: bytes) -> bytes:
"""Upload an MLS key package. Returns fingerprint."""
req = proto.encode_upload_key_package(identity_key, package)
data = await self._rpc(wire.UPLOAD_KEY_PACKAGE, req)
return proto.decode_upload_key_package_response(data)
async def fetch_key_package(self, identity_key: bytes) -> bytes:
"""Fetch an MLS key package."""
req = proto.encode_fetch_key_package(identity_key)
data = await self._rpc(wire.FETCH_KEY_PACKAGE, req)
return proto.decode_fetch_key_package_response(data)
async def upload_hybrid_key(self, identity_key: bytes, hybrid_public_key: bytes) -> None:
"""Upload a hybrid (X25519 + ML-KEM-768) public key."""
req = proto.encode_upload_hybrid_key(identity_key, hybrid_public_key)
await self._rpc(wire.UPLOAD_HYBRID_KEY, req)
async def fetch_hybrid_key(self, identity_key: bytes) -> bytes:
"""Fetch a hybrid public key."""
req = proto.encode_fetch_hybrid_key(identity_key)
data = await self._rpc(wire.FETCH_HYBRID_KEY, req)
return proto.decode_fetch_hybrid_key_response(data)
async def delete_account(self) -> bool:
"""Permanently delete the authenticated account."""
data = await self._rpc(wire.DELETE_ACCOUNT, proto.encode_delete_account())
return proto.decode_delete_account_response(data)
# ------------------------------------------------------------------
# FFI (synchronous) methods
# ------------------------------------------------------------------
def ffi_login(self, username: str, password: str) -> None:
"""Authenticate via OPAQUE using the FFI backend (synchronous)."""
if not self._ffi:
raise ConnectionError("no FFI transport; use QpqClient.connect_ffi()")
self._ffi.login(username, password)
def ffi_send(self, recipient: str, message: bytes) -> None:
"""Send a message via the FFI backend (synchronous)."""
if not self._ffi:
raise ConnectionError("no FFI transport; use QpqClient.connect_ffi()")
self._ffi.send(recipient, message)
def ffi_receive(self, timeout_ms: int = 5000) -> list[str]:
"""Receive messages via the FFI backend (synchronous)."""
if not self._ffi:
raise ConnectionError("no FFI transport; use QpqClient.connect_ffi()")
return self._ffi.receive(timeout_ms)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
async def close(self) -> None:
"""Close all transports."""
if self._quic:
self._quic.close()
self._quic = None
if self._ffi:
self._ffi.close()
self._ffi = None
def close_sync(self) -> None:
"""Close all transports (synchronous variant)."""
if self._quic:
self._quic.close()
self._quic = None
if self._ffi:
self._ffi.close()
self._ffi = None
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
async def _rpc(self, method_id: int, payload: bytes) -> bytes:
if not self._quic:
raise ConnectionError("no QUIC transport; use QpqClient.connect()")
return await self._quic.rpc(method_id, payload)