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