gbp-stack-wasm 1.8.0

Browser/WASM bindings for the Group Protocol Stack.
Documentation

@voluntas-progressus/gbp-stack-wasm — Browser/WASM bindings for the Group Protocol Stack

License: Apache 2.0 NPM

Browser-native WebAssembly bindings for the Group Protocol Stack: a layered, end-to-end encrypted group-messaging protocol family built on top of MLS (RFC 9420).

All cryptography runs entirely in the browser — no native binaries, no server-side key material. The WASM module is compiled from the same Rust source as the other language bindings and exposes an ergonomic JS/TS API via wasm-bindgen.

Layers

┌── application ──────────────────────────────────────────────────────┐
│   GtpClient · GapClient · GspClient   (text · audio · signalling)   │
│   SFrameSession / SFrameEncryptor      (media E2EE over GAP)         │
├─────────────────────────────────────────────────────────────────────┤
│   GroupNode (GBP — IP-like base)                                    │
├─────────────────────────────────────────────────────────────────────┤
│   MlsContext (RFC 9420)                                             │
└─────────────────────────────────────────────────────────────────────┘

The browser/WASM binding now exposes the same gap (audio) / gsp (signalling) / gtp (text) / sframe (media E2EE) surface as the C#, Python and Node bindings — a browser client can place a fully end-to-end-encrypted call.

Bundler compatibility

The package is built with wasm-pack --target bundler. It works out of the box with:

Bundler Notes
Vite Add @vitejs/plugin-react or use vite-plugin-wasm
webpack 5 Requires experiments: { asyncWebAssembly: true }
Rollup Use @rollup/plugin-wasm
Next.js Wrap imports in dynamic(() => import('...'), { ssr: false })

Install

npm install @voluntas-progressus/gbp-stack-wasm

Quick start

import init, { MlsContext, GroupNode, GtpClient } from "@voluntas-progressus/gbp-stack-wasm";

// Load and compile the WASM module once at startup.
await init();

// ── MLS key exchange ────────────────────────────────────────────────
const aliceMls = MlsContext.create("alice");
const bobMls   = MlsContext.create("bob");

// Bob exports a key package so Alice can invite him.
// (In a real app Bob sends this over the signaling channel.)
const welcome = aliceMls.invite(bobMls.keyPackage);
bobMls.acceptWelcome(welcome);
// aliceMls.epoch === bobMls.epoch === 1n

// ── GBP group nodes ─────────────────────────────────────────────────
const groupId   = aliceMls.groupId;            // shared 16-byte id
const aliceNode = GroupNode.create(1, groupId);
const bobNode   = GroupNode.create(2, groupId);

aliceNode.bootstrapAsCreator(aliceMls.epoch);
bobNode.bootstrapAsJoiner(bobMls.epoch, 0);

// ── GTP clients ─────────────────────────────────────────────────────
const gtpAlice = GtpClient.create();
const gtpBob   = GtpClient.create();

// Alice sends a message to Bob (target = 2, messageId = 1n)
const frame = gtpAlice.send(aliceNode, aliceMls, 2, 1n, "hello bob!");
// frame.wire: Uint8Array — hand to your WebSocket / WebRTC transport

// Bob receives it
for (const ev of bobNode.onWire(bobMls, frame.wire)) {
  if (ev.kind === "payload_received" && ev.streamType === 2 /* Text */) {
    const r = gtpBob.accept(ev.plaintext, bobMls.epoch);
    if (r) console.log(r.text);   // → "hello bob!"
  }
}

API reference

MlsContext

Member Description
MlsContext.create(userId: string) Creates a new member identity and an empty MLS group
.keyPackage: Uint8Array TLS-serialised key package (pass to the inviter's invite)
.epoch: bigint Current MLS group epoch
.groupId: Uint8Array 16-byte group identifier
.invite(keyPackageBytes: Uint8Array): Uint8Array Invites another member (merges immediately); returns Welcome bytes
.inviteMany(keyPackages: Uint8Array[]): Uint8Array Invites several members in ONE Add commit (merges immediately, epoch advances once); returns a single Welcome that every joiner accepts with their own key package
.acceptWelcome(welcomeBytes: Uint8Array): void Joins a group from a Welcome produced by invite / inviteMany
.inviteFull(keyPackageBytes: Uint8Array): { commit, welcome } Two-phase invite — stages a pending commit; returns both Commit and Welcome bytes. Broadcast the Commit, then finalizeCommit (or clearPendingCommit to roll back)
.removeMember(leafIndex: number): Uint8Array Stages a Remove commit for leafIndex; returns the Commit to broadcast
.processMessage(msgBytes: Uint8Array): string Applies an inbound MLS message; returns "commit", "application", "proposal" or "external"
.finalizeCommit(): void Merges a pending commit from inviteFull/removeMember (advances the epoch)
.clearPendingCommit(): void Discards a pending commit without applying it (use on ABORT_TRANSITION)
.exportState(): Uint8Array Serialises the full MLS state into an opaque blob (persist to IndexedDB to survive a reload). Contains private keys — store encrypted.
MlsContext.restoreState(blob: Uint8Array): MlsContext Reconstructs a context from exportState output — same epoch / group, ready to send & receive again

GroupNode

Member Description
GroupNode.create(leafIndex: number, groupId: Uint8Array) Creates a GBP node for the given member id and 16-byte group id
.bootstrapAsCreator(epoch: bigint) Drives the node to ACTIVE as the group creator
.bootstrapAsJoiner(epoch: bigint, expectedFirstTid: number) Drives the node to ACTIVE as a joiner
.onWire(mls: MlsContext, wireBytes: Uint8Array): NodeEvent[] Delivers a wire frame; returns decoded events
.exportOutSeq(): Uint8Array Serialises the OUTBOUND sequence counters so a rebuilt node (restart/re-login that restores MLS state) resumes sending above peers' replay high-water-marks — otherwise its frames are dropped as replays. Inbound window is NOT included (rebuilt node re-accepts re-fetched history)
.restoreOutSeq(bytes: Uint8Array) Restores outbound counters from exportOutSeq
.checkTimeouts(): NodeEvent[] Polls timeout events — call ~every 500 ms from the app loop
.lastTransitionId: number transition_id of the last applied epoch transition
.currentEpoch: bigint Current epoch as seen by the GBP layer
.memberId: number This node's member id (leaf index)
.sendControl(mls, target, opcode, transitionId, requestId, args): { wire, to } Sends a control-plane message (ControlOpcode) — transition coordination, capabilities, ACK/NACK
.applyTransition(tid: number) Applies an epoch transition locally
.drainEvents(): NodeEvent[] Drains queued events without consuming wire bytes

GtpClient

Member Description
GtpClient.create() Creates an empty GTP client
.send(node, mls, target, messageId, text, codec?) Encrypts and frames a text message; returns { wire: Uint8Array, to: number }. codec is an optional PayloadCodec (defaults to CBOR)
.accept(plaintext, epoch, codec?) Decodes a GTP payload; returns { text, messageId, senderId, status } or null
.reset() Clears the idempotency set

accept return value:

Field Type Description
text string Decoded message text
messageId bigint Sender-assigned message identifier
senderId number Leaf index of the sender
status "new" | "duplicate" Whether this message id was seen before

GapClient (audio)

Group Audio Protocol — Opus frame delivery with per-source replay protection. The Opus payload is opaque bytes (encode/decode audio with WebCodecs or libopus.wasm); combine with SFrameSession for media E2EE.

Member Description
GapClient.create() Creates an empty GAP client
.send(node, mls, target, mediaSourceId, rtpTimestamp, opus, codec?) Frames one Opus frame; returns { wire, to } or null. Prefer PayloadCodec.FlatBuffers for audio
.accept(plaintext, epoch, codec?) Decodes a GAP payload; returns { status, source, seq, rtpTimestamp, opus } (status is "new"/"late") or null
.reset() Clears outbound counters + replay window (use after an epoch change)

GspClient (signalling)

Group Signaling Protocol — membership / role / stream / codec control signals that drive call membership and mute/stream state.

Member Description
GspClient.create() Creates an empty GSP client
.send(node, mls, target, signalType, roleClaim, requestId, codec?) Sends a bare signal (e.g. SignalType.Join/Leave); returns { wire, to }
.sendWithArgs(node, mls, target, signalType, roleClaim, requestId, args, codec?) Sends a signal carrying CBOR args (MUTE/UNMUTE/ROLE_CHANGE/STREAM_START/STREAM_STOP/CODEC_UPDATE)
.accept(plaintext, epoch, codec?) Decodes a signal; returns { status, signal, signalCode, sender, roleClaim, requestId } (or { status: "duplicate", requestId })
.reset() Clears dedup state

SFrameSession / SFrameEncryptor (media E2EE)

SFrame (draft-ietf-sframe-enc) end-to-end encryption for media frames, keyed off the MLS epoch via the group exporter secret. Derive a fresh session after every epoch change (invite/remove/commit). Wrap each Opus frame with encrypt before handing it to GapClient.send, and decrypt after GapClient.accept.

Member Description
SFrameSession.create(mls, label, suite) Derives a session from the MLS exporter secret. suite is a CipherSuite value
.createEncryptor(mls, leafIndex, label, suite): SFrameEncryptor Creates a sender-side encryptor for leafIndex
.decrypt(payload, aad): { plaintext, senderLeaf } Decrypts an SFrame payload (throws on failure)
SFrameEncryptor.encrypt(plaintext, aad): Uint8Array Encrypts one frame → header ‖ ciphertext ‖ tag

Enums

Exported for parity with the C#/Python/JS SDKs (each is a plain numeric value in JS):

Enum Values
PayloadCodec Cbor = 0, Protobuf = 1, FlatBuffers = 2
SignalType Join = 100, Leave = 101, RoleChange = 102, Mute = 200, Unmute = 201, StreamStart = 300, StreamStop = 301, CodecUpdate = 400
ControlOpcode PrepareTransition = 1CapabilitiesAdvertise = 8, Ack = 9, Nack = 10
CipherSuite Aes128Gcm = 0, Aes256Gcm = 1

NodeEvent shape

Every event object from onWire / checkTimeouts carries kind: string.

kind Extra fields
"payload_received" streamType: number, plaintext: Uint8Array, sequenceNo: number, codec: number
"state_changed" from: string, to: string
"epoch_advanced" epoch: bigint, transitionId: number
"error" code: number, reason: string, fatal: boolean, retryable: boolean
"control" from: number, opcode: number, transitionId: number

Stream types

Value Name Sub-protocol
0 Control GBP control plane
1 Audio GAP (Group Audio Protocol)
2 Text GTP (Group Text Protocol)
3 Signal GSP (Group Signaling Protocol)

Examples

File Description
examples/gtpChat.ts Two-member encrypted text chat (TypeScript, Node.js ≥ 18)
examples/gtpChat.html Standalone browser demo (no bundler needed)
examples/gapAudio.ts Two-member encrypted voice — GAP audio wrapped with SFrame E2EE
examples/gspSignals.ts Call signalling — JOIN, MUTE, STREAM_START via GSP

Testing

Tests are written with wasm-bindgen-test and run inside Node.js:

# Install wasm-pack if needed
cargo install wasm-pack

# Run all WASM tests
wasm-pack test --node crates/gbp/wasm

The test suite covers:

  • MlsContext: create, epoch, keyPackage, groupId, invite / inviteMany / acceptWelcome, inviteFull / finalizeCommit / clearPendingCommit, removeMember, processMessage
  • GroupNode: bootstrap (creator + joiner), onWire, checkTimeouts, getters, sendControl, drainEvents
  • GtpClient: send, accept (roundtrip, unicode, duplicate, sequential, reset, codec selection)
  • GapClient: audio send / accept roundtrip (CBOR + FlatBuffers)
  • GspClient: JOIN / MUTE-with-args signal roundtrip, bad-signal rejection
  • SFrameSession / SFrameEncryptor: encrypt → decrypt roundtrip, AAD mismatch, full GAP+SFrame audio pipeline
  • Two-member group: Alice→Bob, Bob→Alice, bidirectional, epoch sync

Vite configuration example

// vite.config.ts
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";

export default defineConfig({
  plugins: [wasm(), topLevelAwait()],
});

Then in your entry point:

import init from "@voluntas-progressus/gbp-stack-wasm";
await init();

License

Licensed under Apache License, Version 2.0.