Skip to main content

gbp_stack/
lib.rs

1//! C ABI surface for the Group Protocol Stack.
2//!
3//! Designed for consumption from `.NET` (or any FFI-capable runtime) via
4//! P/Invoke. The C ABI is grouped into the following families:
5//!
6//! * **GBP** (`gbp_node_*`) — the IP-like base layer: framing, AEAD, replay
7//!   window, control plane.
8//! * **GTP** (`gtp_client_*`) — text sub-protocol.
9//! * **GAP** (`gap_client_*`) — audio sub-protocol.
10//! * **GSP** (`gsp_client_*`) — signalling sub-protocol.
11//! * **MLS** (`gbp_mls_*`) — RFC 9420 context.
12//! * **SFrame** (`gbp_sframe_*`) — E2EE for GAP audio frames.
13//!
14//! Conventions:
15//!
16//! * **Handle-based** — every long-lived object lives in a Rust-side
17//!   registry keyed by an `i32` handle.
18//! * **GbpBuffer** — binary blobs are returned as `(ptr, len, cap)` triples
19//!   and MUST be released with `gbp_buffer_free`.
20//! * **Owned C-string** — text values are returned as owned `*mut c_char`
21//!   and MUST be released with `gbp_string_free`.
22//! * **Last error** — every fallible call writes to a thread-local error
23//!   slot that callers can read via `gbp_last_error`.
24
25#![allow(unsafe_op_in_unsafe_fn)]
26
27use gbp_stack::core::{ControlOpcode, NodeState, SignalType, StreamType};
28use gbp_stack::{
29    CipherSuite, DeliveredPayload, ErrorObject, Event, GapAccept, GapClient, GbpFrame, GroupNode,
30    GspAccept, GspClient, GtpAccept, GtpClient, MlsContext, OutboundFrame, ProcessedKind,
31    SFrameDecryptor, SFrameEncryptor, SFrameSession, StreamLabel,
32};
33use openmls::prelude::tls_codec::Serialize as _;
34use openmls::prelude::*;
35use serde::Serialize;
36use std::cell::RefCell;
37use std::collections::HashMap;
38use std::ffi::{CString, c_char};
39use std::sync::atomic::{AtomicI32, Ordering};
40use std::sync::{Arc, Mutex};
41
42// ============================================================================
43// Buffer / string types (FFI memory protocol)
44// ============================================================================
45
46/// Binary buffer produced by Rust. The caller MUST release it via
47/// [`gbp_buffer_free`].
48#[repr(C)]
49pub struct GbpBuffer {
50    /// Pointer to the bytes (may be null when `len == 0`).
51    pub ptr: *mut u8,
52    /// Current length in bytes.
53    pub len: usize,
54    /// Capacity used when reconstructing the underlying `Vec` on free.
55    pub cap: usize,
56}
57
58impl GbpBuffer {
59    fn empty() -> Self {
60        Self {
61            ptr: std::ptr::null_mut(),
62            len: 0,
63            cap: 0,
64        }
65    }
66    fn from_vec(mut v: Vec<u8>) -> Self {
67        let ptr = v.as_mut_ptr();
68        let len = v.len();
69        let cap = v.capacity();
70        std::mem::forget(v);
71        Self { ptr, len, cap }
72    }
73}
74
75/// Releases a [`GbpBuffer`].
76///
77/// # Safety
78/// `buf` MUST have been returned by one of the `gbp_*` FFI functions and
79/// MUST NOT have been freed already.
80#[unsafe(no_mangle)]
81pub unsafe extern "C" fn gbp_buffer_free(buf: GbpBuffer) {
82    if buf.ptr.is_null() {
83        return;
84    }
85    unsafe {
86        let _ = Vec::from_raw_parts(buf.ptr, buf.len, buf.cap);
87    }
88}
89
90/// Releases a string previously returned by an FFI function.
91///
92/// # Safety
93/// `ptr` MUST have been returned by one of the `gbp_*` FFI functions.
94#[unsafe(no_mangle)]
95pub unsafe extern "C" fn gbp_string_free(ptr: *mut c_char) {
96    if ptr.is_null() {
97        return;
98    }
99    unsafe {
100        let _ = CString::from_raw(ptr);
101    }
102}
103
104fn alloc_cstring(s: &str) -> *mut c_char {
105    CString::new(s.as_bytes())
106        .unwrap_or_else(|_| CString::new(s.replace('\0', "?")).unwrap())
107        .into_raw()
108}
109
110// ============================================================================
111// Last-error machinery
112// ============================================================================
113
114thread_local! {
115    static LAST_ERROR: RefCell<String> = const { RefCell::new(String::new()) };
116}
117
118fn set_last_error(e: impl ToString) {
119    LAST_ERROR.with(|s| *s.borrow_mut() = e.to_string());
120}
121
122fn clear_last_error() {
123    LAST_ERROR.with(|s| s.borrow_mut().clear());
124}
125
126/// Returns the text of the last error, or an empty string if none.
127#[unsafe(no_mangle)]
128pub extern "C" fn gbp_last_error() -> *mut c_char {
129    LAST_ERROR.with(|s| alloc_cstring(&s.borrow()))
130}
131
132// ============================================================================
133// Handle registries
134// ============================================================================
135
136macro_rules! registry {
137    ($vis:vis $name:ident<$t:ty>) => {
138        $vis struct $name {
139            next: AtomicI32,
140            map: Mutex<HashMap<i32, Arc<Mutex<$t>>>>,
141        }
142        impl $name {
143            fn new() -> Self {
144                Self { next: AtomicI32::new(1), map: Mutex::new(HashMap::new()) }
145            }
146            fn insert(&self, v: $t) -> i32 {
147                let id = self.next.fetch_add(1, Ordering::Relaxed);
148                self.map.lock().unwrap().insert(id, Arc::new(Mutex::new(v)));
149                id
150            }
151            fn remove(&self, id: i32) {
152                self.map.lock().unwrap().remove(&id);
153            }
154            fn get(&self, id: i32) -> Option<Arc<Mutex<$t>>> {
155                self.map.lock().unwrap().get(&id).cloned()
156            }
157        }
158    };
159}
160
161registry!(MlsRegistry<MlsContext>);
162registry!(NodeRegistry<GroupNode>);
163registry!(GtpRegistry<GtpClient>);
164registry!(GapRegistry<GapClient>);
165registry!(GspRegistry<GspClient>);
166registry!(SFrameSessionRegistry<SFrameDecryptor>);
167registry!(SFrameEncryptorRegistry<SFrameEncryptor>);
168
169struct MlsBundles {
170    map: Mutex<HashMap<i32, KeyPackageBundle>>,
171}
172impl MlsBundles {
173    fn new() -> Self {
174        Self {
175            map: Mutex::new(HashMap::new()),
176        }
177    }
178}
179
180fn mls() -> &'static MlsRegistry {
181    use std::sync::OnceLock;
182    static R: OnceLock<MlsRegistry> = OnceLock::new();
183    R.get_or_init(MlsRegistry::new)
184}
185fn mls_bundles() -> &'static MlsBundles {
186    use std::sync::OnceLock;
187    static R: OnceLock<MlsBundles> = OnceLock::new();
188    R.get_or_init(MlsBundles::new)
189}
190fn nodes() -> &'static NodeRegistry {
191    use std::sync::OnceLock;
192    static R: OnceLock<NodeRegistry> = OnceLock::new();
193    R.get_or_init(NodeRegistry::new)
194}
195fn gtps() -> &'static GtpRegistry {
196    use std::sync::OnceLock;
197    static R: OnceLock<GtpRegistry> = OnceLock::new();
198    R.get_or_init(GtpRegistry::new)
199}
200fn gaps() -> &'static GapRegistry {
201    use std::sync::OnceLock;
202    static R: OnceLock<GapRegistry> = OnceLock::new();
203    R.get_or_init(GapRegistry::new)
204}
205fn gsps() -> &'static GspRegistry {
206    use std::sync::OnceLock;
207    static R: OnceLock<GspRegistry> = OnceLock::new();
208    R.get_or_init(GspRegistry::new)
209}
210fn sframe_sessions() -> &'static SFrameSessionRegistry {
211    use std::sync::OnceLock;
212    static R: OnceLock<SFrameSessionRegistry> = OnceLock::new();
213    R.get_or_init(SFrameSessionRegistry::new)
214}
215fn sframe_encryptors() -> &'static SFrameEncryptorRegistry {
216    use std::sync::OnceLock;
217    static R: OnceLock<SFrameEncryptorRegistry> = OnceLock::new();
218    R.get_or_init(SFrameEncryptorRegistry::new)
219}
220
221// ============================================================================
222// Version
223// ============================================================================
224
225/// Returns the FFI library version with a short summary of the layers.
226#[unsafe(no_mangle)]
227pub extern "C" fn gbp_version() -> *mut c_char {
228    alloc_cstring(&format!(
229        "group-protocol-stack {} (gbp + gtp + gap + gsp)",
230        env!("CARGO_PKG_VERSION")
231    ))
232}
233
234// ============================================================================
235// MLS API
236// ============================================================================
237
238/// Creates a new MLS context. Returns the new handle, or `0` on failure.
239///
240/// # Safety
241/// `identity_ptr` MUST be valid for `identity_len` bytes.
242#[unsafe(no_mangle)]
243pub unsafe extern "C" fn gbp_mls_create(identity_ptr: *const u8, identity_len: usize) -> i32 {
244    clear_last_error();
245    let ident = unsafe { std::slice::from_raw_parts(identity_ptr, identity_len) };
246    match MlsContext::new_member(ident) {
247        Ok((ctx, kp)) => {
248            let id = mls().insert(ctx);
249            mls_bundles().map.lock().unwrap().insert(id, kp);
250            id
251        }
252        Err(e) => {
253            set_last_error(e);
254            0
255        }
256    }
257}
258
259/// Destroys an MLS context.
260#[unsafe(no_mangle)]
261pub extern "C" fn gbp_mls_destroy(h: i32) {
262    mls().remove(h);
263    mls_bundles().map.lock().unwrap().remove(&h);
264}
265
266/// Returns the current epoch of the MLS context, or `0` on failure.
267#[unsafe(no_mangle)]
268pub extern "C" fn gbp_mls_epoch(h: i32) -> u64 {
269    mls().get(h).map(|c| c.lock().unwrap().epoch()).unwrap_or(0)
270}
271
272/// Writes the 16-byte group identifier into `out16`.
273///
274/// # Safety
275/// `out16` MUST be valid for 16 bytes.
276#[unsafe(no_mangle)]
277pub unsafe extern "C" fn gbp_mls_group_id(h: i32, out16: *mut u8) -> bool {
278    clear_last_error();
279    let Some(ctx_arc) = mls().get(h) else {
280        set_last_error("invalid MLS handle");
281        return false;
282    };
283    let ctx = ctx_arc.lock().unwrap();
284    let gid = ctx.group_id_16();
285    unsafe { std::ptr::copy_nonoverlapping(gid.as_ptr(), out16, 16) };
286    true
287}
288
289/// Exports the TLS-serialised KeyPackage that can be used to invite this
290/// member into someone else's group.
291#[unsafe(no_mangle)]
292pub extern "C" fn gbp_mls_export_key_package(h: i32) -> GbpBuffer {
293    clear_last_error();
294    let bundles = mls_bundles().map.lock().unwrap();
295    let Some(b) = bundles.get(&h) else {
296        set_last_error("invalid MLS handle");
297        return GbpBuffer::empty();
298    };
299    match b.key_package().tls_serialize_detached() {
300        Ok(b) => GbpBuffer::from_vec(b),
301        Err(e) => {
302            set_last_error(format!("kp serialize: {e:?}"));
303            GbpBuffer::empty()
304        }
305    }
306}
307
308/// Invites the given KeyPackage into the local group. Returns the
309/// TLS-serialised Welcome bytes that the invitee must consume with
310/// [`gbp_mls_accept_welcome`].
311///
312/// # Safety
313/// `kp_ptr` MUST be valid for `kp_len` bytes.
314#[unsafe(no_mangle)]
315pub unsafe extern "C" fn gbp_mls_invite(h: i32, kp_ptr: *const u8, kp_len: usize) -> GbpBuffer {
316    clear_last_error();
317    let bytes = unsafe { std::slice::from_raw_parts(kp_ptr, kp_len) };
318    let Some(ctx_arc) = mls().get(h) else {
319        set_last_error("invalid MLS handle");
320        return GbpBuffer::empty();
321    };
322    let mut ctx = ctx_arc.lock().unwrap();
323    let kp_in = match KeyPackageIn::tls_deserialize_exact_bytes(bytes) {
324        Ok(v) => v,
325        Err(e) => {
326            set_last_error(format!("kp parse: {e:?}"));
327            return GbpBuffer::empty();
328        }
329    };
330    let validated = match kp_in.validate(ctx.provider.crypto(), ProtocolVersion::Mls10) {
331        Ok(v) => v,
332        Err(e) => {
333            set_last_error(format!("kp validate: {e:?}"));
334            return GbpBuffer::empty();
335        }
336    };
337    match ctx.invite(&[validated]) {
338        Ok(welcome) => GbpBuffer::from_vec(welcome),
339        Err(e) => {
340            set_last_error(e);
341            GbpBuffer::empty()
342        }
343    }
344}
345
346/// Invites the given KeyPackage and returns BOTH the MLS Commit (which the
347/// caller MUST broadcast to existing members so they can advance their MLS
348/// epoch) and the Welcome (which the caller MUST unicast to the new joiner).
349///
350/// Buffer layout: `[u32-LE commit_len | commit_bytes | welcome_bytes]`. The
351/// total length minus 4 minus `commit_len` is the welcome length.
352///
353/// # Safety
354/// `kp_ptr` MUST be valid for `kp_len` bytes.
355#[unsafe(no_mangle)]
356pub unsafe extern "C" fn gbp_mls_invite_full(
357    h: i32,
358    kp_ptr: *const u8,
359    kp_len: usize,
360) -> GbpBuffer {
361    clear_last_error();
362    let bytes = unsafe { std::slice::from_raw_parts(kp_ptr, kp_len) };
363    let Some(ctx_arc) = mls().get(h) else {
364        set_last_error("invalid MLS handle");
365        return GbpBuffer::empty();
366    };
367    let mut ctx = ctx_arc.lock().unwrap();
368    let kp_in = match KeyPackageIn::tls_deserialize_exact_bytes(bytes) {
369        Ok(v) => v,
370        Err(e) => {
371            set_last_error(format!("kp parse: {e:?}"));
372            return GbpBuffer::empty();
373        }
374    };
375    let validated = match kp_in.validate(ctx.provider.crypto(), ProtocolVersion::Mls10) {
376        Ok(v) => v,
377        Err(e) => {
378            set_last_error(format!("kp validate: {e:?}"));
379            return GbpBuffer::empty();
380        }
381    };
382    match ctx.invite_full(&[validated]) {
383        Ok((commit, welcome)) => {
384            let mut out = Vec::with_capacity(4 + commit.len() + welcome.len());
385            out.extend_from_slice(&(commit.len() as u32).to_le_bytes());
386            out.extend_from_slice(&commit);
387            out.extend_from_slice(&welcome);
388            GbpBuffer::from_vec(out)
389        }
390        Err(e) => {
391            set_last_error(e);
392            GbpBuffer::empty()
393        }
394    }
395}
396
397/// Removes the member at the given MLS LeafIndex and returns the
398/// TLS-serialised Commit. Caller MUST broadcast the Commit to remaining
399/// members so they advance their MLS epoch.
400#[unsafe(no_mangle)]
401pub extern "C" fn gbp_mls_remove(h: i32, leaf_index: u32) -> GbpBuffer {
402    clear_last_error();
403    let Some(ctx_arc) = mls().get(h) else {
404        set_last_error("invalid MLS handle");
405        return GbpBuffer::empty();
406    };
407    let mut ctx = ctx_arc.lock().unwrap();
408    match ctx.remove_members(&[leaf_index]) {
409        Ok(commit) => GbpBuffer::from_vec(commit),
410        Err(e) => {
411            set_last_error(e);
412            GbpBuffer::empty()
413        }
414    }
415}
416
417/// Applies a Commit (or staged Proposal) message to the local MLS group.
418/// Returns:
419///   1 — Commit applied (epoch advanced)
420///   2 — Application message (no-op for GBP)
421///   3 — Proposal staged
422///   4 — External message (no group state change)
423///   0 — failure (see `gbp_last_error`).
424///
425/// # Safety
426/// `msg_ptr` MUST be valid for `msg_len` bytes.
427#[unsafe(no_mangle)]
428pub unsafe extern "C" fn gbp_mls_process_message(
429    h: i32,
430    msg_ptr: *const u8,
431    msg_len: usize,
432) -> u32 {
433    clear_last_error();
434    let bytes = unsafe { std::slice::from_raw_parts(msg_ptr, msg_len) };
435    let Some(ctx_arc) = mls().get(h) else {
436        set_last_error("invalid MLS handle");
437        return 0;
438    };
439    let mut ctx = ctx_arc.lock().unwrap();
440    match ctx.process_message(bytes) {
441        Ok(ProcessedKind::Commit) => 1,
442        Ok(ProcessedKind::Application) => 2,
443        Ok(ProcessedKind::Proposal) => 3,
444        Ok(ProcessedKind::External) => 4,
445        Err(e) => {
446            set_last_error(e);
447            0
448        }
449    }
450}
451
452/// Merges any pending commit produced by `gbp_mls_invite_full` or
453/// `gbp_mls_remove`. Returns `true` on success, `false` on failure.
454#[unsafe(no_mangle)]
455pub extern "C" fn gbp_mls_finalize_commit(h: i32) -> bool {
456    clear_last_error();
457    let Some(ctx_arc) = mls().get(h) else {
458        set_last_error("invalid MLS handle");
459        return false;
460    };
461    let mut ctx = ctx_arc.lock().unwrap();
462    match ctx.finalize_pending_commit() {
463        Ok(()) => true,
464        Err(e) => {
465            set_last_error(e);
466            false
467        }
468    }
469}
470
471/// Discards any pending commit without applying it. Used on
472/// `ABORT_TRANSITION` to roll back to the pre-commit MLS state.
473#[unsafe(no_mangle)]
474pub extern "C" fn gbp_mls_clear_pending_commit(h: i32) -> bool {
475    clear_last_error();
476    let Some(ctx_arc) = mls().get(h) else {
477        set_last_error("invalid MLS handle");
478        return false;
479    };
480    let mut ctx = ctx_arc.lock().unwrap();
481    match ctx.clear_pending_commit() {
482        Ok(()) => true,
483        Err(e) => {
484            set_last_error(e);
485            false
486        }
487    }
488}
489
490/// Replaces the local group with the one described by the given Welcome.
491///
492/// # Safety
493/// `welcome_ptr` MUST be valid for `welcome_len` bytes.
494#[unsafe(no_mangle)]
495pub unsafe extern "C" fn gbp_mls_accept_welcome(
496    h: i32,
497    welcome_ptr: *const u8,
498    welcome_len: usize,
499) -> bool {
500    clear_last_error();
501    let bytes = unsafe { std::slice::from_raw_parts(welcome_ptr, welcome_len) };
502    let Some(ctx_arc) = mls().get(h) else {
503        set_last_error("invalid MLS handle");
504        return false;
505    };
506    let mut ctx = ctx_arc.lock().unwrap();
507    match ctx.accept_welcome(bytes) {
508        Ok(()) => true,
509        Err(e) => {
510            set_last_error(e);
511            false
512        }
513    }
514}
515
516// ============================================================================
517// GBP node API (the IP-like base layer)
518// ============================================================================
519
520/// Creates a new GBP node and returns its handle.
521///
522/// # Safety
523/// `group_id_16` MUST be valid for 16 bytes.
524#[unsafe(no_mangle)]
525pub unsafe extern "C" fn gbp_node_create(member_id: u32, group_id_16: *const u8) -> i32 {
526    clear_last_error();
527    let mut gid = [0u8; 16];
528    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
529    nodes().insert(GroupNode::new(member_id, gid))
530}
531
532/// Destroys a GBP node.
533#[unsafe(no_mangle)]
534pub extern "C" fn gbp_node_destroy(h: i32) {
535    nodes().remove(h);
536}
537
538/// Drives the node to `ACTIVE` as a creator.
539#[unsafe(no_mangle)]
540pub extern "C" fn gbp_node_bootstrap_creator(h: i32, epoch: u64) -> bool {
541    let Some(n_arc) = nodes().get(h) else {
542        return false;
543    };
544    n_arc.lock().unwrap().bootstrap_as_creator(epoch);
545    true
546}
547
548/// Drives the node to `ACTIVE` as a joiner. `expected_first_tid` lets the
549/// joiner pre-arm pending transition state so that the first
550/// `EXECUTE_TRANSITION` after Welcome is accepted; pass `0` if the joiner
551/// recovered out-of-band and is already current.
552#[unsafe(no_mangle)]
553pub extern "C" fn gbp_node_bootstrap_joiner(h: i32, epoch: u64, expected_first_tid: u32) -> bool {
554    let Some(n_arc) = nodes().get(h) else {
555        return false;
556    };
557    n_arc
558        .lock()
559        .unwrap()
560        .bootstrap_as_joiner(epoch, expected_first_tid);
561    true
562}
563
564/// Returns the current `NodeState` encoded as `u32`.
565#[unsafe(no_mangle)]
566pub extern "C" fn gbp_node_state(h: i32) -> u32 {
567    nodes()
568        .get(h)
569        .map(|n| n.lock().unwrap().state as u32)
570        .unwrap_or(u32::MAX)
571}
572
573/// Returns the node's current epoch.
574#[unsafe(no_mangle)]
575pub extern "C" fn gbp_node_epoch(h: i32) -> u64 {
576    nodes()
577        .get(h)
578        .map(|n| n.lock().unwrap().current_epoch)
579        .unwrap_or(0)
580}
581
582/// Returns the node's last applied `transition_id`.
583#[unsafe(no_mangle)]
584pub extern "C" fn gbp_node_last_transition_id(h: i32) -> u32 {
585    nodes()
586        .get(h)
587        .map(|n| n.lock().unwrap().last_transition_id)
588        .unwrap_or(0)
589}
590
591/// Forcibly sets the node's `current_epoch` (intended for tests of late
592/// peers and `EPOCH_MISMATCH` recovery).
593#[unsafe(no_mangle)]
594pub extern "C" fn gbp_node_set_epoch(h: i32, epoch: u64) -> bool {
595    let Some(n_arc) = nodes().get(h) else {
596        return false;
597    };
598    n_arc.lock().unwrap().current_epoch = epoch;
599    true
600}
601
602/// Applies an epoch transition locally.
603#[unsafe(no_mangle)]
604pub extern "C" fn gbp_node_apply_transition(h: i32, tid: u32) -> bool {
605    let Some(n_arc) = nodes().get(h) else {
606        return false;
607    };
608    n_arc.lock().unwrap().apply_transition(tid);
609    true
610}
611
612/// Sends a control plane message on Stream 0.
613///
614/// The returned buffer layout is `[u32-LE target | wire]`.
615///
616/// # Safety
617/// `args_ptr` MUST be valid for `args_len` bytes.
618#[unsafe(no_mangle)]
619pub unsafe extern "C" fn gbp_node_send_control(
620    nh: i32,
621    mh: i32,
622    target: u32,
623    opcode: u16,
624    transition_id: u32,
625    request_id: u32,
626    args_ptr: *const u8,
627    args_len: usize,
628) -> GbpBuffer {
629    clear_last_error();
630    let op = match ControlOpcode::try_from(opcode) {
631        Ok(o) => o,
632        Err(_) => {
633            set_last_error(format!("bad opcode 0x{opcode:04X}"));
634            return GbpBuffer::empty();
635        }
636    };
637    let args = if args_len == 0 {
638        Vec::new()
639    } else {
640        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }.to_vec()
641    };
642    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
643    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
644        set_last_error("bad node/mls handle");
645        return GbpBuffer::empty();
646    };
647    let mut n = n_arc.lock().unwrap();
648    let mut m = m_arc.lock().unwrap();
649    match n.send_control(&mut *m, target, op, transition_id, request_id, args) {
650        Ok(of) => outbound_to_buffer(of),
651        Err(e) => {
652            set_last_error(e.to_string());
653            GbpBuffer::empty()
654        }
655    }
656}
657
658/// Feeds wire bytes to the node. Returns a JSON-encoded array of events.
659///
660/// # Safety
661/// `wire_ptr` MUST be valid for `wire_len` bytes.
662#[unsafe(no_mangle)]
663pub unsafe extern "C" fn gbp_node_on_wire(
664    nh: i32,
665    mh: i32,
666    wire_ptr: *const u8,
667    wire_len: usize,
668) -> *mut c_char {
669    clear_last_error();
670    let wire = unsafe { std::slice::from_raw_parts(wire_ptr, wire_len) };
671    let (n_arc, m_arc) = (nodes().get(nh), mls().get(mh));
672    let (Some(n_arc), Some(m_arc)) = (n_arc, m_arc) else {
673        set_last_error("bad node/mls handle");
674        return alloc_cstring("[]");
675    };
676    let mut n = n_arc.lock().unwrap();
677    let mut m = m_arc.lock().unwrap();
678    let events = match n.on_wire(&mut *m, wire) {
679        Ok(e) => e,
680        Err(e) => {
681            set_last_error(e.to_string());
682            return alloc_cstring("[]");
683        }
684    };
685    alloc_cstring(&events_to_json(&events))
686}
687
688/// Drains the queued events (without consuming any wire bytes).
689#[unsafe(no_mangle)]
690pub extern "C" fn gbp_node_drain_events(nh: i32) -> *mut c_char {
691    let Some(n_arc) = nodes().get(nh) else {
692        return alloc_cstring("[]");
693    };
694    alloc_cstring(&events_to_json(&n_arc.lock().unwrap().drain_events()))
695}
696
697fn outbound_to_buffer(of: OutboundFrame) -> GbpBuffer {
698    let mut out = Vec::with_capacity(4 + of.wire.len());
699    out.extend_from_slice(&of.to.to_le_bytes());
700    out.extend_from_slice(&of.wire);
701    GbpBuffer::from_vec(out)
702}
703
704// ============================================================================
705// GTP client API
706// ============================================================================
707
708/// Creates a stateful GTP client (idempotency tracking).
709#[unsafe(no_mangle)]
710pub extern "C" fn gtp_client_create() -> i32 {
711    gtps().insert(GtpClient::new())
712}
713
714/// Destroys a GTP client.
715#[unsafe(no_mangle)]
716pub extern "C" fn gtp_client_destroy(h: i32) {
717    gtps().remove(h);
718}
719
720/// Clears the client state. Intended for use after an epoch change.
721#[unsafe(no_mangle)]
722pub extern "C" fn gtp_client_reset(h: i32) {
723    if let Some(c) = gtps().get(h) {
724        c.lock().unwrap().reset();
725    }
726}
727
728/// Sends a text message via GTP.
729///
730/// # Safety
731/// `text_ptr` MUST be valid UTF-8 for `text_len` bytes.
732#[unsafe(no_mangle)]
733pub unsafe extern "C" fn gtp_client_send(
734    ch: i32,
735    nh: i32,
736    mh: i32,
737    target: u32,
738    message_id: u64,
739    text_ptr: *const u8,
740    text_len: usize,
741) -> GbpBuffer {
742    clear_last_error();
743    let text = unsafe { std::slice::from_raw_parts(text_ptr, text_len) };
744    let text = match std::str::from_utf8(text) {
745        Ok(s) => s,
746        Err(e) => {
747            set_last_error(format!("utf8: {e}"));
748            return GbpBuffer::empty();
749        }
750    };
751    let (c_arc, n_arc, m_arc) = (gtps().get(ch), nodes().get(nh), mls().get(mh));
752    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
753        set_last_error("bad handle");
754        return GbpBuffer::empty();
755    };
756    let mut c = c_arc.lock().unwrap();
757    let mut n = n_arc.lock().unwrap();
758    let mut m = m_arc.lock().unwrap();
759    match c.send(&mut *n, &mut *m, target, message_id, text) {
760        Ok(of) => outbound_to_buffer(of),
761        Err(e) => {
762            set_last_error(e.to_string());
763            GbpBuffer::empty()
764        }
765    }
766}
767
768/// Accepts a plaintext payload that the GBP layer surfaced via a
769/// `payload_received` event. Returns a JSON object of the form
770/// `{"status":"new|duplicate|error", ...}`.
771///
772/// `current_epoch` is the receiver node's current epoch — the client uses
773/// it to auto-reset its idempotency state when the epoch advances.
774///
775/// # Safety
776/// `pt_ptr` MUST be valid for `pt_len` bytes.
777#[unsafe(no_mangle)]
778pub unsafe extern "C" fn gtp_client_accept(
779    ch: i32,
780    current_epoch: u64,
781    pt_ptr: *const u8,
782    pt_len: usize,
783) -> *mut c_char {
784    clear_last_error();
785    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
786    let Some(c_arc) = gtps().get(ch) else {
787        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
788    };
789    let mut c = c_arc.lock().unwrap();
790    #[derive(Serialize)]
791    struct Out<'a> {
792        status: &'a str,
793        sender: Option<u32>,
794        message_id: Option<u64>,
795        text: Option<String>,
796        reason: Option<String>,
797    }
798    let out = match c.accept(pt, current_epoch) {
799        Ok(GtpAccept::New(m)) => Out {
800            status: "new",
801            sender: Some(m.sender_id),
802            message_id: Some(m.message_id),
803            text: Some(m.text().unwrap_or("<binary>").to_string()),
804            reason: None,
805        },
806        Ok(GtpAccept::Duplicate(m)) => Out {
807            status: "duplicate",
808            sender: Some(m.sender_id),
809            message_id: Some(m.message_id),
810            text: Some(m.text().unwrap_or("<binary>").to_string()),
811            reason: None,
812        },
813        Err(e) => Out {
814            status: "error",
815            sender: None,
816            message_id: None,
817            text: None,
818            reason: Some(e.to_string()),
819        },
820    };
821    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
822}
823
824// ============================================================================
825// GAP client API
826// ============================================================================
827
828/// Creates a stateful GAP client.
829#[unsafe(no_mangle)]
830pub extern "C" fn gap_client_create() -> i32 {
831    gaps().insert(GapClient::new())
832}
833
834/// Destroys a GAP client.
835#[unsafe(no_mangle)]
836pub extern "C" fn gap_client_destroy(h: i32) {
837    gaps().remove(h);
838}
839
840/// Clears the client state. Intended for use after an epoch change.
841#[unsafe(no_mangle)]
842pub extern "C" fn gap_client_reset(h: i32) {
843    if let Some(c) = gaps().get(h) {
844        c.lock().unwrap().reset();
845    }
846}
847
848/// Sends an Opus audio frame via GAP.
849///
850/// # Safety
851/// `opus_ptr` MUST be valid for `opus_len` bytes.
852#[unsafe(no_mangle)]
853pub unsafe extern "C" fn gap_client_send(
854    ch: i32,
855    nh: i32,
856    mh: i32,
857    target: u32,
858    media_source_id: u32,
859    rtp_timestamp: u64,
860    opus_ptr: *const u8,
861    opus_len: usize,
862) -> GbpBuffer {
863    clear_last_error();
864    let opus = unsafe { std::slice::from_raw_parts(opus_ptr, opus_len) }.to_vec();
865    let (c_arc, n_arc, m_arc) = (gaps().get(ch), nodes().get(nh), mls().get(mh));
866    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
867        set_last_error("bad handle");
868        return GbpBuffer::empty();
869    };
870    let mut c = c_arc.lock().unwrap();
871    let mut n = n_arc.lock().unwrap();
872    let mut m = m_arc.lock().unwrap();
873    match c.send(
874        &mut *n,
875        &mut *m,
876        target,
877        media_source_id,
878        rtp_timestamp,
879        opus,
880    ) {
881        Ok(of) => outbound_to_buffer(of),
882        Err(e) => {
883            set_last_error(e.to_string());
884            GbpBuffer::empty()
885        }
886    }
887}
888
889/// Accepts a GAP audio payload.
890///
891/// # Safety
892/// `pt_ptr` MUST be valid for `pt_len` bytes.
893#[unsafe(no_mangle)]
894pub unsafe extern "C" fn gap_client_accept(
895    ch: i32,
896    current_epoch: u64,
897    pt_ptr: *const u8,
898    pt_len: usize,
899) -> *mut c_char {
900    clear_last_error();
901    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
902    let Some(c_arc) = gaps().get(ch) else {
903        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
904    };
905    let mut c = c_arc.lock().unwrap();
906    #[derive(Serialize)]
907    struct Out<'a> {
908        status: &'a str,
909        source: Option<u32>,
910        seq: Option<u32>,
911        bytes: Option<usize>,
912        reason: Option<String>,
913    }
914    let out = match c.accept(pt, current_epoch) {
915        Ok(GapAccept::New(p)) => Out {
916            status: "new",
917            source: Some(p.media_source_id),
918            seq: Some(p.rtp_sequence),
919            bytes: Some(p.opus_frame.len()),
920            reason: None,
921        },
922        Ok(GapAccept::Late(p)) => Out {
923            status: "late",
924            source: Some(p.media_source_id),
925            seq: Some(p.rtp_sequence),
926            bytes: Some(p.opus_frame.len()),
927            reason: None,
928        },
929        Err(e) => Out {
930            status: "error",
931            source: None,
932            seq: None,
933            bytes: None,
934            reason: Some(e.to_string()),
935        },
936    };
937    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
938}
939
940// ============================================================================
941// GSP client API
942// ============================================================================
943
944/// Creates a stateful GSP client.
945#[unsafe(no_mangle)]
946pub extern "C" fn gsp_client_create() -> i32 {
947    gsps().insert(GspClient::new())
948}
949
950/// Destroys a GSP client.
951#[unsafe(no_mangle)]
952pub extern "C" fn gsp_client_destroy(h: i32) {
953    gsps().remove(h);
954}
955
956/// Clears the client state. Intended for use after an epoch change.
957#[unsafe(no_mangle)]
958pub extern "C" fn gsp_client_reset(h: i32) {
959    if let Some(c) = gsps().get(h) {
960        c.lock().unwrap().reset();
961    }
962}
963
964/// Sends a GSP signal.
965#[unsafe(no_mangle)]
966pub extern "C" fn gsp_client_send(
967    ch: i32,
968    nh: i32,
969    mh: i32,
970    target: u32,
971    signal_type: u32,
972    role_claim: u32,
973    request_id: u32,
974) -> GbpBuffer {
975    clear_last_error();
976    let sig = match SignalType::try_from(signal_type) {
977        Ok(s) => s,
978        Err(_) => {
979            set_last_error(format!("bad signal {signal_type}"));
980            return GbpBuffer::empty();
981        }
982    };
983    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
984    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
985        set_last_error("bad handle");
986        return GbpBuffer::empty();
987    };
988    let mut c = c_arc.lock().unwrap();
989    let mut n = n_arc.lock().unwrap();
990    let mut m = m_arc.lock().unwrap();
991    match c.send(&mut *n, &mut *m, target, sig, role_claim, request_id) {
992        Ok(of) => outbound_to_buffer(of),
993        Err(e) => {
994            set_last_error(e.to_string());
995            GbpBuffer::empty()
996        }
997    }
998}
999
1000/// Sends a GSP signal with opcode-specific CBOR `args` bytes.
1001/// Use this for signals that require structured arguments (MUTE, UNMUTE,
1002/// ROLE_CHANGE, STREAM_START, STREAM_STOP, CODEC_UPDATE).
1003///
1004/// # Safety
1005/// `args_ptr` MUST be valid for `args_len` bytes.
1006#[unsafe(no_mangle)]
1007pub unsafe extern "C" fn gsp_client_send_with_args(
1008    ch: i32,
1009    nh: i32,
1010    mh: i32,
1011    target: u32,
1012    signal_type: u32,
1013    role_claim: u32,
1014    request_id: u32,
1015    args_ptr: *const u8,
1016    args_len: usize,
1017) -> GbpBuffer {
1018    clear_last_error();
1019    let args: &[u8] = if args_len == 0 || args_ptr.is_null() {
1020        &[]
1021    } else {
1022        unsafe { std::slice::from_raw_parts(args_ptr, args_len) }
1023    };
1024    let sig = match SignalType::try_from(signal_type) {
1025        Ok(s) => s,
1026        Err(_) => {
1027            set_last_error(format!("bad signal {signal_type}"));
1028            return GbpBuffer::empty();
1029        }
1030    };
1031    let (c_arc, n_arc, m_arc) = (gsps().get(ch), nodes().get(nh), mls().get(mh));
1032    let (Some(c_arc), Some(n_arc), Some(m_arc)) = (c_arc, n_arc, m_arc) else {
1033        set_last_error("bad handle");
1034        return GbpBuffer::empty();
1035    };
1036    let mut c = c_arc.lock().unwrap();
1037    let mut n = n_arc.lock().unwrap();
1038    let mut m = m_arc.lock().unwrap();
1039    match c.send_with_args(&mut *n, &mut *m, target, sig, role_claim, request_id, args) {
1040        Ok(of) => outbound_to_buffer(of),
1041        Err(e) => {
1042            set_last_error(e.to_string());
1043            GbpBuffer::empty()
1044        }
1045    }
1046}
1047
1048/// Accepts a GSP signal payload.
1049///
1050/// `current_epoch` is the receiver node's current epoch — the client uses
1051/// it to auto-reset its dedup state when the epoch advances.
1052///
1053/// # Safety
1054/// `pt_ptr` MUST be valid for `pt_len` bytes.
1055#[unsafe(no_mangle)]
1056pub unsafe extern "C" fn gsp_client_accept(
1057    ch: i32,
1058    current_epoch: u64,
1059    pt_ptr: *const u8,
1060    pt_len: usize,
1061) -> *mut c_char {
1062    clear_last_error();
1063    let pt = unsafe { std::slice::from_raw_parts(pt_ptr, pt_len) };
1064    let Some(c_arc) = gsps().get(ch) else {
1065        return alloc_cstring(r#"{"status":"error","reason":"bad client"}"#);
1066    };
1067    let mut c = c_arc.lock().unwrap();
1068    #[derive(Serialize)]
1069    struct Out<'a> {
1070        status: &'a str,
1071        signal: Option<&'a str>,
1072        signal_code: Option<u32>,
1073        sender: Option<u32>,
1074        role_claim: Option<u32>,
1075        request_id: Option<u32>,
1076        reason: Option<String>,
1077    }
1078    let out = match c.accept(pt, current_epoch) {
1079        Ok(GspAccept {
1080            signal,
1081            sender_id,
1082            role_claim,
1083            request_id,
1084        }) => Out {
1085            status: "new",
1086            signal: Some(signal.name()),
1087            signal_code: Some(signal as u32),
1088            sender: Some(sender_id),
1089            role_claim: Some(role_claim),
1090            request_id: Some(request_id),
1091            reason: None,
1092        },
1093        Err(gbp_stack::GspError::DuplicateRequest(rid)) => Out {
1094            status: "duplicate",
1095            signal: None,
1096            signal_code: None,
1097            sender: None,
1098            role_claim: None,
1099            request_id: Some(rid),
1100            reason: None,
1101        },
1102        Err(e) => Out {
1103            status: "error",
1104            signal: None,
1105            signal_code: None,
1106            sender: None,
1107            role_claim: None,
1108            request_id: None,
1109            reason: Some(e.to_string()),
1110        },
1111    };
1112    alloc_cstring(&serde_json::to_string(&out).unwrap_or_default())
1113}
1114
1115// ============================================================================
1116// Codec helpers (used for tests that need malformed frames)
1117// ============================================================================
1118
1119/// CBOR-encodes a [`gbp::GbpFrame`] with an arbitrary version byte.
1120///
1121/// # Safety
1122/// Every pointer MUST be valid for the corresponding declared length.
1123#[unsafe(no_mangle)]
1124pub unsafe extern "C" fn gbp_frame_encode_v(
1125    version: u8,
1126    group_id_16: *const u8,
1127    epoch: u64,
1128    transition_id: u32,
1129    stream_type: u32,
1130    stream_id: u32,
1131    flags: u16,
1132    sequence_no: u32,
1133    payload_ptr: *const u8,
1134    payload_len: usize,
1135) -> GbpBuffer {
1136    clear_last_error();
1137    let mut gid = [0u8; 16];
1138    unsafe { std::ptr::copy_nonoverlapping(group_id_16, gid.as_mut_ptr(), 16) };
1139    let st_u8 = StreamType::try_from(stream_type)
1140        .map(|s| s as u8)
1141        .unwrap_or(stream_type as u8);
1142    let payload: Vec<u8> = if payload_len == 0 || payload_ptr.is_null() {
1143        Vec::new()
1144    } else {
1145        unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) }.to_vec()
1146    };
1147    let frame = gbp_stack::gbp::GbpFrame {
1148        version,
1149        group_id: serde_bytes::ByteBuf::from(gid.to_vec()),
1150        epoch,
1151        transition_id,
1152        stream_type: st_u8,
1153        stream_id,
1154        flags,
1155        sequence_no,
1156        payload_size: payload.len() as u32,
1157        encrypted_payload: serde_bytes::ByteBuf::from(payload),
1158    };
1159    GbpBuffer::from_vec(frame.to_cbor())
1160}
1161
1162/// Returns a CBOR-encoded `ErrorObject` for the given code.
1163#[unsafe(no_mangle)]
1164pub extern "C" fn gbp_error_lookup(code: u16) -> GbpBuffer {
1165    use gbp_stack::core::errors::ErrorSpec;
1166    match ErrorSpec::lookup(code) {
1167        Some(spec) => GbpBuffer::from_vec(ErrorObject::from_spec(spec, spec.name).to_cbor()),
1168        None => {
1169            set_last_error(format!("unknown error code 0x{code:04X}"));
1170            GbpBuffer::empty()
1171        }
1172    }
1173}
1174
1175#[allow(dead_code)]
1176fn _link(_f: &GbpFrame, _l: StreamLabel) {}
1177
1178// ============================================================================
1179// Event JSON
1180// ============================================================================
1181
1182#[derive(Serialize)]
1183#[serde(tag = "kind", rename_all = "snake_case")]
1184enum EventDto<'a> {
1185    StateChanged {
1186        from: String,
1187        to: String,
1188    },
1189    PayloadReceived {
1190        stream_type: &'a str,
1191        stream_type_code: u32,
1192        stream_id: u32,
1193        sequence_no: u32,
1194        flags: u16,
1195        plaintext_b64: String,
1196    },
1197    Control {
1198        from: u32,
1199        opcode: &'a str,
1200        opcode_code: u16,
1201        transition_id: u32,
1202        request_id: u32,
1203        args_b64: String,
1204    },
1205    Error {
1206        code: u16,
1207        code_hex: String,
1208        class: u8,
1209        retryable: bool,
1210        fatal: bool,
1211        reason: String,
1212    },
1213    EpochAdvanced {
1214        epoch: u64,
1215        transition_id: u32,
1216    },
1217    CoordinatorElectionNeeded {},
1218    BecameCoordinator {},
1219    CoordinatorClaim {
1220        claimant: u32,
1221    },
1222}
1223
1224fn b64(b: &[u8]) -> String {
1225    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1226    let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
1227    let mut i = 0;
1228    while i + 3 <= b.len() {
1229        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8) | (b[i + 2] as u32);
1230        out.push(A[(n >> 18) as usize & 0x3F] as char);
1231        out.push(A[(n >> 12) as usize & 0x3F] as char);
1232        out.push(A[(n >> 6) as usize & 0x3F] as char);
1233        out.push(A[n as usize & 0x3F] as char);
1234        i += 3;
1235    }
1236    let rem = b.len() - i;
1237    if rem == 1 {
1238        let n = (b[i] as u32) << 16;
1239        out.push(A[(n >> 18) as usize & 0x3F] as char);
1240        out.push(A[(n >> 12) as usize & 0x3F] as char);
1241        out.push('=');
1242        out.push('=');
1243    } else if rem == 2 {
1244        let n = ((b[i] as u32) << 16) | ((b[i + 1] as u32) << 8);
1245        out.push(A[(n >> 18) as usize & 0x3F] as char);
1246        out.push(A[(n >> 12) as usize & 0x3F] as char);
1247        out.push(A[(n >> 6) as usize & 0x3F] as char);
1248        out.push('=');
1249    }
1250    out
1251}
1252
1253fn dto<'a>(e: &'a Event) -> EventDto<'a> {
1254    match e {
1255        Event::StateChanged { from, to } => EventDto::StateChanged {
1256            from: from.to_string(),
1257            to: to.to_string(),
1258        },
1259        Event::PayloadReceived(DeliveredPayload {
1260            stream_type,
1261            stream_id,
1262            sequence_no,
1263            flags,
1264            plaintext,
1265        }) => EventDto::PayloadReceived {
1266            stream_type: match stream_type {
1267                StreamType::Control => "control",
1268                StreamType::Audio => "audio",
1269                StreamType::Text => "text",
1270                StreamType::Signal => "signal",
1271            },
1272            stream_type_code: *stream_type as u32,
1273            stream_id: *stream_id,
1274            sequence_no: *sequence_no,
1275            flags: *flags,
1276            plaintext_b64: b64(plaintext),
1277        },
1278        Event::Control {
1279            from,
1280            opcode,
1281            transition_id,
1282            request_id,
1283            args,
1284        } => EventDto::Control {
1285            from: *from,
1286            opcode: opcode.name(),
1287            opcode_code: *opcode as u16,
1288            transition_id: *transition_id,
1289            request_id: *request_id,
1290            args_b64: b64(args),
1291        },
1292        Event::Error {
1293            code,
1294            class,
1295            retryable,
1296            fatal,
1297            reason,
1298        } => EventDto::Error {
1299            code: *code,
1300            code_hex: format!("0x{code:04X}"),
1301            class: *class as u8,
1302            retryable: *retryable,
1303            fatal: *fatal,
1304            reason: reason.clone(),
1305        },
1306        Event::EpochAdvanced {
1307            epoch,
1308            transition_id,
1309        } => EventDto::EpochAdvanced {
1310            epoch: *epoch,
1311            transition_id: *transition_id,
1312        },
1313        Event::CoordinatorElectionNeeded => EventDto::CoordinatorElectionNeeded {},
1314        Event::BecameCoordinator => EventDto::BecameCoordinator {},
1315        Event::CoordinatorClaim { claimant } => EventDto::CoordinatorClaim {
1316            claimant: *claimant,
1317        },
1318    }
1319}
1320
1321fn events_to_json(events: &[Event]) -> String {
1322    let dtos: Vec<EventDto> = events.iter().map(dto).collect();
1323    serde_json::to_string(&dtos).unwrap_or_else(|_| "[]".to_string())
1324}
1325
1326#[allow(dead_code)]
1327const _STATES: [NodeState; 7] = [
1328    NodeState::Idle,
1329    NodeState::Connecting,
1330    NodeState::EstablishingGroup,
1331    NodeState::Active,
1332    NodeState::Resyncing,
1333    NodeState::Failed,
1334    NodeState::Closed,
1335];
1336
1337// ============================================================================
1338// SFrame API  (`gbp_sframe_*`)
1339// ============================================================================
1340
1341/// Creates an SFrame session from an existing MLS context.
1342///
1343/// Derives `sframe_base_key = MLS.ExportSecret(label, epoch_be8, 32)` and
1344/// stores a [`SFrameDecryptor`] in the session registry.  Each SFrame
1345/// session corresponds to one MLS epoch; create a new session after every
1346/// commit.
1347///
1348/// Returns a positive session handle, or `0` on failure (check
1349/// [`gbp_last_error`]).
1350///
1351/// * `mls_handle` — handle from [`gbp_mls_create`].
1352/// * `suite` — `0` = AES-128-GCM, `1` = AES-256-GCM.
1353/// * `label_ptr` / `label_len` — UTF-8 export label (e.g. `"gbp/sframe v1"`).
1354///
1355/// # Safety
1356/// `label_ptr` MUST be valid UTF-8 for `label_len` bytes.
1357#[unsafe(no_mangle)]
1358pub unsafe extern "C" fn gbp_sframe_session_create(
1359    mls_handle: i32,
1360    suite: u8,
1361    label_ptr: *const u8,
1362    label_len: usize,
1363) -> i32 {
1364    clear_last_error();
1365    let suite = match CipherSuite::from_u8(suite) {
1366        Some(s) => s,
1367        None => {
1368            set_last_error(format!("unknown ciphersuite {suite}"));
1369            return 0;
1370        }
1371    };
1372    let label = unsafe {
1373        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1374            Ok(s) => s,
1375            Err(e) => {
1376                set_last_error(e);
1377                return 0;
1378            }
1379        }
1380    };
1381    let Some(mls_arc) = mls().get(mls_handle) else {
1382        set_last_error("invalid MLS handle");
1383        return 0;
1384    };
1385    let mls = mls_arc.lock().unwrap();
1386    match SFrameSession::from_mls(&mls, label, suite) {
1387        Ok(session) => sframe_sessions().insert(session.decryptor()),
1388        Err(e) => {
1389            set_last_error(e);
1390            0
1391        }
1392    }
1393}
1394
1395/// Frees an SFrame session created by [`gbp_sframe_session_create`].
1396#[unsafe(no_mangle)]
1397pub extern "C" fn gbp_sframe_session_free(handle: i32) {
1398    sframe_sessions().remove(handle);
1399}
1400
1401/// Creates an encryptor for the local sender (`leaf_index`) within an epoch.
1402///
1403/// The session handle MUST be the one returned by [`gbp_sframe_session_create`]
1404/// for the same epoch.  One encryptor per sender; do **not** share across
1405/// threads.
1406///
1407/// Returns a positive encryptor handle, or `0` on failure.
1408///
1409/// # Safety
1410/// `mls_handle` and `session_handle` must be valid.
1411#[unsafe(no_mangle)]
1412pub unsafe extern "C" fn gbp_sframe_encryptor_create(
1413    mls_handle: i32,
1414    session_handle: i32,
1415    leaf_index: u32,
1416    suite: u8,
1417    label_ptr: *const u8,
1418    label_len: usize,
1419) -> i32 {
1420    clear_last_error();
1421    let suite = match CipherSuite::from_u8(suite) {
1422        Some(s) => s,
1423        None => {
1424            set_last_error(format!("unknown ciphersuite {suite}"));
1425            return 0;
1426        }
1427    };
1428    let label = unsafe {
1429        match std::str::from_utf8(std::slice::from_raw_parts(label_ptr, label_len)) {
1430            Ok(s) => s,
1431            Err(e) => {
1432                set_last_error(e);
1433                return 0;
1434            }
1435        }
1436    };
1437    // Verify the session handle exists (keeps the API consistent).
1438    if sframe_sessions().get(session_handle).is_none() {
1439        set_last_error("invalid session handle");
1440        return 0;
1441    }
1442    let Some(mls_arc) = mls().get(mls_handle) else {
1443        set_last_error("invalid MLS handle");
1444        return 0;
1445    };
1446    let mls = mls_arc.lock().unwrap();
1447    match SFrameSession::from_mls(&mls, label, suite) {
1448        Ok(session) => sframe_encryptors().insert(session.encryptor(leaf_index)),
1449        Err(e) => {
1450            set_last_error(e);
1451            0
1452        }
1453    }
1454}
1455
1456/// Frees an encryptor created by [`gbp_sframe_encryptor_create`].
1457#[unsafe(no_mangle)]
1458pub extern "C" fn gbp_sframe_encryptor_free(handle: i32) {
1459    sframe_encryptors().remove(handle);
1460}
1461
1462/// Encrypts one audio frame.
1463///
1464/// Returns a [`GbpBuffer`] containing `sframe_header ‖ ciphertext ‖ tag`.
1465/// The caller MUST free it with [`gbp_buffer_free`].
1466///
1467/// On error returns an empty buffer and sets [`gbp_last_error`].
1468///
1469/// * `aad_ptr` / `aad_len` — additional authenticated data (e.g. RTP header);
1470///   pass a null pointer and `0` if none.
1471///
1472/// # Safety
1473/// All pointer/length pairs MUST be valid for their respective lengths.
1474#[unsafe(no_mangle)]
1475pub unsafe extern "C" fn gbp_sframe_encrypt(
1476    enc_handle: i32,
1477    plaintext_ptr: *const u8,
1478    plaintext_len: usize,
1479    aad_ptr: *const u8,
1480    aad_len: usize,
1481) -> GbpBuffer {
1482    clear_last_error();
1483    let Some(enc_arc) = sframe_encryptors().get(enc_handle) else {
1484        set_last_error("invalid encryptor handle");
1485        return GbpBuffer::empty();
1486    };
1487    let plaintext = unsafe { std::slice::from_raw_parts(plaintext_ptr, plaintext_len) };
1488    let aad = if aad_ptr.is_null() || aad_len == 0 {
1489        &[][..]
1490    } else {
1491        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1492    };
1493    let mut enc = enc_arc.lock().unwrap();
1494    match enc.encrypt(plaintext, aad) {
1495        Ok(payload) => GbpBuffer::from_vec(payload),
1496        Err(e) => {
1497            set_last_error(e);
1498            GbpBuffer::empty()
1499        }
1500    }
1501}
1502
1503/// Decrypts one SFrame payload.
1504///
1505/// Returns a [`GbpBuffer`] containing the plaintext Opus frame.
1506/// The caller MUST free it with [`gbp_buffer_free`].
1507///
1508/// On success, `*sender_leaf_out` is set to the sender's leaf index.
1509/// On error returns an empty buffer and sets [`gbp_last_error`].
1510///
1511/// # Safety
1512/// All pointer/length pairs MUST be valid for their respective lengths.
1513/// `sender_leaf_out` MUST be a valid non-null pointer to a `u32`.
1514#[unsafe(no_mangle)]
1515pub unsafe extern "C" fn gbp_sframe_decrypt(
1516    session_handle: i32,
1517    payload_ptr: *const u8,
1518    payload_len: usize,
1519    aad_ptr: *const u8,
1520    aad_len: usize,
1521    sender_leaf_out: *mut u32,
1522) -> GbpBuffer {
1523    clear_last_error();
1524    let Some(session_arc) = sframe_sessions().get(session_handle) else {
1525        set_last_error("invalid session handle");
1526        return GbpBuffer::empty();
1527    };
1528    let payload = unsafe { std::slice::from_raw_parts(payload_ptr, payload_len) };
1529    let aad = if aad_ptr.is_null() || aad_len == 0 {
1530        &[][..]
1531    } else {
1532        unsafe { std::slice::from_raw_parts(aad_ptr, aad_len) }
1533    };
1534    let mut dec = session_arc.lock().unwrap();
1535    match dec.decrypt(payload, aad) {
1536        Ok((plaintext, leaf)) => {
1537            if !sender_leaf_out.is_null() {
1538                unsafe {
1539                    *sender_leaf_out = leaf;
1540                }
1541            }
1542            GbpBuffer::from_vec(plaintext)
1543        }
1544        Err(e) => {
1545            set_last_error(e);
1546            GbpBuffer::empty()
1547        }
1548    }
1549}
1550
1551#[cfg(test)]
1552mod tests {
1553    use super::b64;
1554
1555    #[test]
1556    fn b64_empty() {
1557        assert_eq!(b64(b""), "");
1558    }
1559
1560    #[test]
1561    fn b64_single_byte() {
1562        // 0xFF = "255" → base64: b"255" → "//8="
1563        // 0xFF >> 2 = 63 = '/', (0xFF & 0x03) << 4 = 60 = '8', pad = "=="
1564        // "255" in base64: first char = table[(255 >> 2)] = table[63] = '/'
1565        // second char = table[((255 & 3) << 4) | 0] = table[60] = '8'
1566        // pad = "==" → "//8="
1567        // Actually let me just check with known vectors.
1568        let s = b64(b"f");
1569        // "f" = 0x66 = 102. chars: table[102>>2=25]='Z', table[((102&3)<<4)=32]='g', pad="==" → "Zg=="
1570        assert_eq!(s, "Zg==");
1571    }
1572
1573    #[test]
1574    fn b64_two_bytes() {
1575        let s = b64(b"fo");
1576        // "fo" → 0x66, 0x6F. n = (0x66<<16)|(0x6F<<8)|0 = 0x666F00
1577        // c1 = table[n>>18] = table[0x666F00>>18=1] = 'B' ... let me just use a known-vector.
1578        // Actually "fo" in base64 is "Zm8="  (0x66='Z', combined='m8', pad='=')
1579        assert_eq!(s, "Zm8=");
1580    }
1581
1582    #[test]
1583    fn b64_three_bytes() {
1584        let s = b64(b"foo");
1585        // "foo" in standard base64 is "Zm9v"
1586        assert_eq!(s, "Zm9v");
1587    }
1588
1589    #[test]
1590    fn b64_known_vectors() {
1591        // Independently verified against RFC 4648 test vectors and Python base64.
1592        assert_eq!(b64(b""), "");
1593        assert_eq!(b64(b"f"), "Zg==");
1594        assert_eq!(b64(b"fo"), "Zm8=");
1595        assert_eq!(b64(b"foo"), "Zm9v");
1596        assert_eq!(b64(b"foob"), "Zm9vYg==");
1597        assert_eq!(b64(b"fooba"), "Zm9vYmE=");
1598        assert_eq!(b64(b"foobar"), "Zm9vYmFy");
1599    }
1600
1601    #[test]
1602    fn b64_padding_roundtrip() {
1603        // Verify padding is correct by checking every single-byte input 0..255
1604        for b in 0u8..=255 {
1605            let input = [b];
1606            let enc = b64(&input);
1607            // Every 1-byte base64 string is exactly 4 chars.
1608            assert_eq!(enc.len(), 4, "len mismatch for 0x{b:02X}: {enc}");
1609            // The last two chars must be padding.
1610            assert!(enc.ends_with("=="), "missing padding for 0x{b:02X}: {enc}");
1611        }
1612    }
1613}