feat: Sprint 3 — C FFI bindings, WASM compilation, Python example, SDK docs
- Create quicproquo-ffi crate with 7 extern "C" functions: connect, login, send, receive, disconnect, last_error, free_string (produces libquicproquo_ffi.so and .a) - Feature-gate quicproquo-core for WASM: identity, hybrid_kem, safety_numbers, sealed_sender, app_message, padding, transcript all compile to wasm32-unknown-unknown - Add Python ctypes example (examples/python/qpq_client.py) with QpqClient wrapper class and CLI - Add SDK documentation: FFI reference, WASM guide, qpq-gen generators - Update Dockerfile for quicproquo-ffi workspace member
This commit is contained in:
68
examples/python/README.md
Normal file
68
examples/python/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# quicproquo Python FFI Client
|
||||
|
||||
Python wrapper around `libquicproquo_ffi` using `ctypes`.
|
||||
|
||||
## Build the FFI library
|
||||
|
||||
```bash
|
||||
cargo build --release -p quicproquo-ffi
|
||||
```
|
||||
|
||||
This produces:
|
||||
|
||||
- Linux: `target/release/libquicproquo_ffi.so`
|
||||
- macOS: `target/release/libquicproquo_ffi.dylib`
|
||||
- Windows: `target/release/quicproquo_ffi.dll`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python examples/python/qpq_client.py \
|
||||
--server 127.0.0.1:7000 \
|
||||
--ca-cert server-cert.der \
|
||||
--server-name localhost \
|
||||
--username alice \
|
||||
--password secret
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```bash
|
||||
python examples/python/qpq_client.py \
|
||||
--server 127.0.0.1:7000 \
|
||||
--ca-cert server-cert.der \
|
||||
--username alice --password secret \
|
||||
--send-to bob --message "hello from Python"
|
||||
```
|
||||
|
||||
### Receive messages
|
||||
|
||||
```bash
|
||||
python examples/python/qpq_client.py \
|
||||
--server 127.0.0.1:7000 \
|
||||
--ca-cert server-cert.der \
|
||||
--username bob --password secret \
|
||||
--receive --timeout 10000
|
||||
```
|
||||
|
||||
## Library path
|
||||
|
||||
The script auto-detects the library in `target/release/` or `target/debug/`.
|
||||
Override with `QPQ_FFI_LIB`:
|
||||
|
||||
```bash
|
||||
export QPQ_FFI_LIB=/path/to/libquicproquo_ffi.so
|
||||
```
|
||||
|
||||
## Programmatic usage
|
||||
|
||||
```python
|
||||
from qpq_client import QpqClient, _find_library, _load_library
|
||||
|
||||
lib = _load_library(_find_library())
|
||||
with QpqClient(lib) as client:
|
||||
client.connect("127.0.0.1:7000", "server-cert.der", "localhost")
|
||||
client.login("alice", "secret")
|
||||
client.send("bob", "hello")
|
||||
messages = client.receive(timeout_ms=5000)
|
||||
```
|
||||
231
examples/python/qpq_client.py
Normal file
231
examples/python/qpq_client.py
Normal file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""quicproquo Python client -- ctypes wrapper around libquicproquo_ffi.
|
||||
|
||||
Usage:
|
||||
python qpq_client.py --server 127.0.0.1:7000 \\
|
||||
--ca-cert server-cert.der \\
|
||||
--server-name localhost \\
|
||||
--username alice --password secret \\
|
||||
[--send-to bob --message "hello"] \\
|
||||
[--receive --timeout 5000]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from ctypes import (
|
||||
POINTER,
|
||||
c_char_p,
|
||||
c_int,
|
||||
c_size_t,
|
||||
c_uint32,
|
||||
c_void_p,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status codes (must match quicproquo-ffi/src/lib.rs)
|
||||
# ---------------------------------------------------------------------------
|
||||
QPQ_OK = 0
|
||||
QPQ_ERROR = 1
|
||||
QPQ_AUTH_FAILED = 2
|
||||
QPQ_TIMEOUT = 3
|
||||
QPQ_NOT_CONNECTED = 4
|
||||
|
||||
STATUS_NAMES = {
|
||||
QPQ_OK: "OK",
|
||||
QPQ_ERROR: "ERROR",
|
||||
QPQ_AUTH_FAILED: "AUTH_FAILED",
|
||||
QPQ_TIMEOUT: "TIMEOUT",
|
||||
QPQ_NOT_CONNECTED: "NOT_CONNECTED",
|
||||
}
|
||||
|
||||
|
||||
def _status_name(code: int) -> str:
|
||||
return STATUS_NAMES.get(code, f"UNKNOWN({code})")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_library() -> str:
|
||||
"""Locate libquicproquo_ffi shared library."""
|
||||
env = os.environ.get("QPQ_FFI_LIB")
|
||||
if env:
|
||||
return env
|
||||
|
||||
# Walk up from this script to find the cargo target directory.
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
for profile in ("release", "debug"):
|
||||
for ext in ("so", "dylib", "dll"):
|
||||
candidate = repo_root / "target" / profile / f"libquicproquo_ffi.{ext}"
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
print(
|
||||
"ERROR: Could not find libquicproquo_ffi. "
|
||||
"Build with: cargo build --release -p quicproquo-ffi\n"
|
||||
"Or set QPQ_FFI_LIB=/path/to/libquicproquo_ffi.so",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _load_library(path: str) -> ctypes.CDLL:
|
||||
"""Load the FFI library and declare function signatures."""
|
||||
lib = ctypes.cdll.LoadLibrary(path)
|
||||
|
||||
# qpq_connect(server, ca_cert, server_name) -> *mut QpqHandle
|
||||
lib.qpq_connect.argtypes = [c_char_p, c_char_p, c_char_p]
|
||||
lib.qpq_connect.restype = c_void_p
|
||||
|
||||
# qpq_login(handle, username, password) -> i32
|
||||
lib.qpq_login.argtypes = [c_void_p, c_char_p, c_char_p]
|
||||
lib.qpq_login.restype = c_int
|
||||
|
||||
# qpq_send(handle, recipient, message, message_len) -> i32
|
||||
lib.qpq_send.argtypes = [c_void_p, c_char_p, ctypes.POINTER(ctypes.c_uint8), c_size_t]
|
||||
lib.qpq_send.restype = c_int
|
||||
|
||||
# qpq_receive(handle, timeout_ms, out_json) -> i32
|
||||
lib.qpq_receive.argtypes = [c_void_p, c_uint32, POINTER(c_char_p)]
|
||||
lib.qpq_receive.restype = c_int
|
||||
|
||||
# qpq_disconnect(handle)
|
||||
lib.qpq_disconnect.argtypes = [c_void_p]
|
||||
lib.qpq_disconnect.restype = None
|
||||
|
||||
# qpq_last_error(handle) -> *const c_char
|
||||
lib.qpq_last_error.argtypes = [c_void_p]
|
||||
lib.qpq_last_error.restype = c_char_p
|
||||
|
||||
# qpq_free_string(ptr)
|
||||
lib.qpq_free_string.argtypes = [c_char_p]
|
||||
lib.qpq_free_string.restype = None
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class QpqClient:
|
||||
"""Synchronous Python client wrapping the quicproquo C FFI."""
|
||||
|
||||
def __init__(self, lib: ctypes.CDLL):
|
||||
self._lib = lib
|
||||
self._handle: int | None = None
|
||||
|
||||
def connect(self, server: str, ca_cert: str, server_name: str) -> None:
|
||||
handle = self._lib.qpq_connect(
|
||||
server.encode(), ca_cert.encode(), server_name.encode(),
|
||||
)
|
||||
if not handle:
|
||||
raise ConnectionError("qpq_connect returned null -- check server address and CA cert")
|
||||
self._handle = handle
|
||||
|
||||
def login(self, username: str, password: str) -> None:
|
||||
rc = self._lib.qpq_login(self._handle, username.encode(), password.encode())
|
||||
if rc != QPQ_OK:
|
||||
err = self._last_error()
|
||||
raise RuntimeError(f"qpq_login failed ({_status_name(rc)}): {err}")
|
||||
|
||||
def send(self, recipient: str, message: str) -> None:
|
||||
msg_bytes = message.encode()
|
||||
buf = (ctypes.c_uint8 * len(msg_bytes))(*msg_bytes)
|
||||
rc = self._lib.qpq_send(self._handle, recipient.encode(), buf, len(msg_bytes))
|
||||
if rc != QPQ_OK:
|
||||
err = self._last_error()
|
||||
raise RuntimeError(f"qpq_send failed ({_status_name(rc)}): {err}")
|
||||
|
||||
def receive(self, timeout_ms: int = 5000) -> list[str]:
|
||||
out = c_char_p()
|
||||
rc = self._lib.qpq_receive(self._handle, c_uint32(timeout_ms), ctypes.byref(out))
|
||||
if rc == QPQ_TIMEOUT:
|
||||
return []
|
||||
if rc != QPQ_OK:
|
||||
err = self._last_error()
|
||||
raise RuntimeError(f"qpq_receive failed ({_status_name(rc)}): {err}")
|
||||
|
||||
result: list[str] = []
|
||||
if out.value:
|
||||
result = json.loads(out.value.decode())
|
||||
self._lib.qpq_free_string(out)
|
||||
return result
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._handle:
|
||||
self._lib.qpq_disconnect(self._handle)
|
||||
self._handle = None
|
||||
|
||||
def _last_error(self) -> str:
|
||||
err = self._lib.qpq_last_error(self._handle)
|
||||
if err:
|
||||
return err.decode(errors="replace")
|
||||
return "(no error message)"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.disconnect()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="quicproquo Python FFI client")
|
||||
parser.add_argument("--server", required=True, help="Server address (host:port)")
|
||||
parser.add_argument("--ca-cert", required=True, help="Path to CA certificate (DER)")
|
||||
parser.add_argument("--server-name", default="localhost", help="TLS server name")
|
||||
parser.add_argument("--username", required=True, help="OPAQUE username")
|
||||
parser.add_argument("--password", required=True, help="OPAQUE password")
|
||||
parser.add_argument("--send-to", help="Recipient username for sending a message")
|
||||
parser.add_argument("--message", help="Message text to send")
|
||||
parser.add_argument("--receive", action="store_true", help="Receive pending messages")
|
||||
parser.add_argument("--timeout", type=int, default=5000, help="Receive timeout (ms)")
|
||||
args = parser.parse_args()
|
||||
|
||||
lib_path = _find_library()
|
||||
print(f"Using library: {lib_path}")
|
||||
lib = _load_library(lib_path)
|
||||
|
||||
with QpqClient(lib) as client:
|
||||
print(f"Connecting to {args.server}...")
|
||||
client.connect(args.server, args.ca_cert, args.server_name)
|
||||
print("Connected.")
|
||||
|
||||
print(f"Logging in as {args.username}...")
|
||||
client.login(args.username, args.password)
|
||||
print("Logged in.")
|
||||
|
||||
if args.send_to and args.message:
|
||||
print(f"Sending to {args.send_to}: {args.message}")
|
||||
client.send(args.send_to, args.message)
|
||||
print("Sent.")
|
||||
|
||||
if args.receive:
|
||||
print(f"Receiving (timeout={args.timeout}ms)...")
|
||||
messages = client.receive(timeout_ms=args.timeout)
|
||||
if messages:
|
||||
print(f"Received {len(messages)} message(s):")
|
||||
for i, msg in enumerate(messages, 1):
|
||||
print(f" [{i}] {msg}")
|
||||
else:
|
||||
print("No messages received.")
|
||||
|
||||
print("Disconnecting...")
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user