dave

Safe native MoonBit bindings for Discord's official libdave C API

dave
libdave
discord
e2ee
mls
native
ffi
Download zip
Author
Version
0.1.0
License
Apache-2.0
Last updated
19 hours ago
Downloads
34

#gaato/dave

Safe native MoonBit bindings for Discord's official libdave C API.

gaato/dave owns the unsafe FFI boundary and exposes opaque MoonBit types for MLS sessions, key ratchets, and media encryptors/decryptors. It is a protocol and media-crypto library, not a Discord client: Gateway and voice WebSocket opcodes, participant bookkeeping, transition timing, RTP/UDP transport, and recovery policy stay in the application.

Real DAVE operations target MoonBit's native backend and pin upstream libdave v1.2.0/cpp. The package also type-checks on JavaScript so it can remain in a multi-target dependency graph; there available() is false and native-handle constructors raise DaveError::LibraryUnavailable.

#Using the API

Add the released module to the consumer and import the root package from moon.pkg:

moon add gaato/dave@0.1.0

import {
"gaato/dave",
}

Create an MLS session and obtain the key package that the Discord DAVE orchestration layer sends to the voice server:

///|
fn make_key_package() -> Bytes raise @dave.DaveError {
let session = @dave.Session::new(
protocol_version=1,
group_id=42UL,
self_user_id=100UL,
)
session.key_package()
}

The application sets the external sender before processing proposals, then feeds proposal, commit, and Welcome messages into the session. The external sender cannot be replaced while pending or established MLS group state exists; call reset, replace it, and then reinitialize when a replacement is needed. reset itself retains the configured external sender, matching libdave, so an ordinary reinitialize recreates pending group state with that sender. Once an epoch is established, obtain per-user KeyRatchet values and install them into an Encryptor or Decryptor. Outbound SSRCs must be assigned a supported codec before encrypted frames are processed.

Check available() before starting an optional DAVE path, or let constructors raise DaveError::LibraryUnavailable when DAVE is required.

#Ownership and errors

  • Session, KeyRatchet, Encryptor, and Decryptor own finalizable native handles. Copying one of these MoonBit values aliases the same mutable native state, so access to a single value must be serialized by the application.
  • Byte strings returned from native code are copied into MoonBit-owned memory. Installing a KeyRatchet copies its native key state into the media object.
  • Session::new always requests libdave's transient signing-key behavior in v0.1. Native authentication-session IDs and persisted-key lookup are not exposed by the safe API.
  • Invalid use, unavailable native support, and wrapper/native failures raise DaveError. MLS commit and Welcome rejections remain protocol outcomes in CommitResult::Failed or WelcomeResult::Failed. Applied commit and Welcome results both carry roster deltas; RosterChange::Remove represents an empty signature returned by libdave.

#Verification boundary

The official libdave v1.2.0 prebuilt C ABI has PERSISTENT_KEYS disabled. Each Session therefore has a different transient signing identity, even for the same Discord user, and Session::reinitialize rotates that identity again. Concurrent sessions must not be treated as sharing a persistent DAVE identity.

Session::current_group_pairwise_fingerprint_blocking compares signing keys in the currently established MLS group only. It can produce the current group's DAVE verification code, but it does not provide identity continuity, a durable verified-contact state, or persistent verification across reconnects, reinitialization, or a replacement Session. Applications must discard any stored fingerprint, display code, or trust decision when that boundary changes. The fingerprint-format version is fixed to the canonical protocol value zero; callers cannot substitute another version. The method waits for libdave's asynchronous fingerprint worker and must not be called on an event-loop thread.

Formatting the current-group result is an explicit second step:

let fingerprint = session.current_group_pairwise_fingerprint_blocking(
user_id=peer_user_id,
)
let display_code = @dave.pairwise_verification_code(fingerprint)

#Native runtime

Opt in once from a native Moon build to download the matching official libdave archive, verify its pinned size and SHA-256 digest, and store the complete extraction in a user cache:

env MBT_DAVE_REQUIRE_NATIVE=1 moon build --target native --release --deny-warn

The shared library is loaded at runtime; it is not installed system-wide or embedded in the Mooncake. Builds without MBT_DAVE_REQUIRE_NATIVE=1, including JavaScript builds, do not bootstrap a host library.

See Native libdave runtime for cache paths, offline and preseeded builds, environment overrides, and loader order.

The upstream release provides these host assets:

HostArchitectureCI coverage
Linuxx86-64native build and tests
LinuxARM64native build and tests
macOSARM64native build and tests
macOSx86-64asset bootstrap and digest smoke test
Windowsx86-64native build and tests

The official MoonBit installer currently has no Darwin x86-64 toolchain, so the macOS Intel job cannot compile or execute the binding. Windows ARM64 and other hosts have no pinned upstream libdave asset. Host-selected bootstrap does not support cross-compilation.

Upstream's Linux binaries require glibc 2.38 and GLIBCXX 3.4.32. Applications on older distributions need a compatible self-built library supplied through MBT_DAVE_NATIVE_LIB or a newer runtime environment.

#Verification

On a supported host, bootstrap the pinned runtime and verify both backends with:

moon fmt --check node --test test/build.test.js moon check --target all --deny-warn env MBT_DAVE_REQUIRE_NATIVE=1 moon build --target native --release --deny-warn env MBT_DAVE_REQUIRE_NATIVE=1 moon test --target native --release --deny-warn moon test --target js --release --deny-warn moon info --target native moon package --list

The offline Node downloader tests under test/ are repository-only and are excluded from the published Mooncake.

The pinned native bridge smoke exercises the safe MoonBit/C boundary through a successful single-member commit, roster and authenticator decoding, key-ratchet installation, and encrypted media round trip. It does not cover a server-signed Add proposal, Welcome processing, or multi-party Discord DAVE conformance. Those paths require libdave's upstream test-only ExternalSender helper or messages produced by a live Discord voice session.

#Upstream and licensing

This project is an independent binding. It is not affiliated with or endorsed by Discord. libdave and its bundled dependencies retain their upstream licenses and notices; see THIRD_PARTY.md. The MoonBit binding itself is licensed under Apache-2.0.

#Checked API examples

The documentation examples below are compile-checked without requiring a live Discord session.

///|
fn make_key_package_for_docs() -> Bytes raise @dave.DaveError {
let session = @dave.Session::new(
protocol_version=1,
group_id=42UL,
self_user_id=100UL,
)
session.key_package()
}

///|
fn current_group_verification_code_for_docs(
session : @dave.Session,
peer_user_id : UInt64,
) -> String raise @dave.DaveError {
let fingerprint = session.current_group_pairwise_fingerprint_blocking(
user_id=peer_user_id,
)
@dave.pairwise_verification_code(fingerprint)
}

///|
test "documented native flows typecheck" {
let _ = make_key_package_for_docs
let _ = current_group_verification_code_for_docs
}

DaveError

pub(all) suberror DaveError {
LibraryUnavailable(reason~ : String)
InvalidArgument(operation~ : String, reason~ : String)
InvalidState(operation~ : String, reason~ : String)
MlsOperationFailed(operation~ : String, failure~ : MlsFailure)
FingerprintFailed(user_id~ : UInt64, reason~ : String)
EncryptFailed(reason~ : EncryptFailure)
DecryptFailed(reason~ : DecryptFailure)
NativeFailure(operation~ : String, reason~ : String)
} derive(Eq,
Debug
)

Errors reported by the safe DAVE wrapper.

Protocol-level commit and Welcome rejection is returned as CommitResult::Failed or WelcomeResult::Failed, rather than raised as an error. Raised errors represent invalid wrapper use, unavailable native support, or an operation that could not produce a protocol result.

Codec

pub(all) enum Codec {
Opus
Vp8
Vp9
H264
H265
Av1
} derive(Eq,
Debug
)

A codec supported by libdave's frame processors.

libdave's Unknown codec is deliberately omitted because it cannot safely be used for encryption.

CommitResult

pub(all) enum CommitResult {
Applied(changes~ : Array[RosterChange])
Ignored
Failed(failure~ : MlsFailure)
} derive(Eq,
Debug
)

The protocol outcome of processing an MLS commit.

DecryptFailure

pub(all) enum DecryptFailure {
DecryptionFailure
MissingKeyRatchet
InvalidNonce
MissingCryptor
Unknown(code~ : Int)
} derive(Eq,
Debug
)

Why media-frame decryption failed.

Decryptor

pub struct Decryptor {
// private fields
}

A stateful media-frame decryptor.

Applications normally keep one decryptor per remote participant. The native handle is released by a finalizer; copied Decryptor values alias the same mutable state and must not be used concurrently.

Decryptor::decrypt

fn Decryptor::decrypt(self : Decryptor, media_type~ : MediaType, encrypted_frame : Bytes) -> Bytes raise DaveError

Decrypt one media frame and return a new MoonBit-owned byte string.

Decryptor::new

fn Decryptor::new() -> Decryptor raise DaveError

Create a media-frame decryptor.

Decryptor::transition_to_key_ratchet

fn Decryptor::transition_to_key_ratchet(self : Decryptor, key_ratchet : KeyRatchet) -> Unit raise DaveError

Begin libdave's transition to a copy of key_ratchet.

libdave retains old decryption keys for its built-in transition window.

Decryptor::transition_to_passthrough_mode

fn Decryptor::transition_to_passthrough_mode(self : Decryptor, enabled~ : Bool) -> Unit raise DaveError

Transition to or from unencrypted passthrough.

EncryptFailure

pub(all) enum EncryptFailure {
EncryptionFailure
MissingKeyRatchet
MissingCryptor
TooManyAttempts
Unknown(code~ : Int)
} derive(Eq,
Debug
)

Why media-frame encryption failed.

Encryptor

pub struct Encryptor {
// private fields
}

A stateful media-frame encryptor.

Assign each SSRC to its codec before encrypting frames for that SSRC. The native handle is released by a finalizer; copied Encryptor values alias the same mutable state and must not be used concurrently.

Encryptor::assign_ssrc_to_codec

fn Encryptor::assign_ssrc_to_codec(self : Encryptor, ssrc~ : UInt, codec~ : Codec) -> Unit raise DaveError

Associate an RTP SSRC with its media codec.

Encryptor::encrypt

fn Encryptor::encrypt(self : Encryptor, media_type~ : MediaType, ssrc~ : UInt, frame : Bytes) -> Bytes raise DaveError

Encrypt one media frame and return a new MoonBit-owned byte string.

SSRC/codec assignment is required while encryption is active. Native passthrough mode accepts frames without an assignment, matching libdave.

Encryptor::has_key_ratchet

fn Encryptor::has_key_ratchet(self : Encryptor) -> Bool

Whether an outbound key ratchet has been installed.

Encryptor::is_passthrough_mode

fn Encryptor::is_passthrough_mode(self : Encryptor) -> Bool

Whether outbound media is currently passed through unencrypted.

Encryptor::new

fn Encryptor::new() -> Encryptor raise DaveError

Create a media-frame encryptor.

Encryptor::protocol_version

fn Encryptor::protocol_version(self : Encryptor) -> UInt16

Return the protocol version selected by the active outbound ratchet.

Encryptor::set_key_ratchet

fn Encryptor::set_key_ratchet(self : Encryptor, key_ratchet : KeyRatchet) -> Unit raise DaveError

Install a copy of key_ratchet for outbound media.

Encryptor::set_passthrough_mode

fn Encryptor::set_passthrough_mode(self : Encryptor, enabled~ : Bool) -> Unit raise DaveError

Enable or disable unencrypted passthrough.

KeyRatchet

pub struct KeyRatchet {
// private fields
}

A key ratchet derived from an established MLS session.

Installing a ratchet into an encryptor or decryptor copies its native key state. The KeyRatchet therefore remains independently owned and may be collected after installation.

MediaType

pub(all) enum MediaType {
Audio
Video
} derive(Eq,
Debug
)

The kind of media carried by a DAVE frame.

MlsFailure

pub(all) struct MlsFailure {
source : String
reason : String
} derive(Eq,
Debug
)

Details captured from libdave's MLS failure callback.

RosterChange

pub(all) enum RosterChange {
Upsert(user_id~ : UInt64, signature_key~ : Bytes)
Remove(user_id~ : UInt64)
} derive(Eq,
Debug
)

A change to the P-256 signature-public-key roster returned by an applied MLS commit or Welcome.

Session

pub struct Session {
// private fields
}

A stateful MLS session.

The native handle is owned by this value and released by a finalizer. A copied Session aliases the same mutable native session, so calls on the same session must be serialized by the application.

Session::current_group_pairwise_fingerprint_blocking

fn Session::current_group_pairwise_fingerprint_blocking(self : Session, user_id~ : UInt64) -> Bytes raise DaveError

Compute a pairwise fingerprint for the currently established MLS group.

This proves only that the two signing keys in the current group produce the same comparison value. With the official v1.2.0 prebuilt C ABI, signing identities differ between concurrent Session values and rotate on every reinitialize, so this is not persistent identity verification or DAVE identity continuity. Discard the result when the session is reinitialized or replaced. The DAVE fingerprint-format version is fixed to zero by this API, as required by the canonical protocol.

The operation is deliberately synchronous and may block for an expensive scrypt computation. Do not call it on a latency-sensitive cooperative event loop.

Session::key_package

fn Session::key_package(self : Session) -> Bytes raise DaveError

Create a fresh, single-use marshalled MLS key package.

Session::key_ratchet

fn Session::key_ratchet(self : Session, user_id~ : UInt64) -> KeyRatchet? raise DaveError

Derive a media key ratchet for user_id from the established MLS epoch.

Session::last_epoch_authenticator

fn Session::last_epoch_authenticator(self : Session) -> Bytes? raise DaveError

Return the authenticator of the established MLS epoch, if one exists.

Session::new

fn Session::new(protocol_version~ : UInt16, group_id~ : UInt64, self_user_id~ : UInt64) -> Session raise DaveError

Create and initialize an MLS session.

The official libdave v1.2.0 prebuilt C ABI has persistent keys disabled, so each Session receives a new transient signing identity. Persisted native authentication-session IDs are intentionally not exposed by this v0.1 API.

Session::process_commit

fn Session::process_commit(self : Session, commit : Bytes) -> CommitResult raise DaveError

Process an incoming MLS commit without collapsing its protocol outcome into an exception. An empty serialized commit is rejected before FFI.

Session::process_proposals

fn Session::process_proposals(self : Session, proposals : Bytes, recognized_user_ids~ : ArrayView[UInt64]) -> Bytes raise DaveError

Process a complete libdave proposals payload.

The nonempty payload includes its leading revoke/append byte. On success libdave returns one serialized commit followed by an optional serialized Welcome. Processing requires pending or established MLS group state, normally created by set_external_sender after initialization. A successful call always returns a nonempty outbound commit/Welcome payload. recognized_user_ids is the caller's explicit identity-recognition policy; the wrapper adds this session's local user ID when it is absent.

Session::process_welcome

fn Session::process_welcome(self : Session, welcome : Bytes, recognized_user_ids~ : ArrayView[UInt64]) -> WelcomeResult raise DaveError

Process an incoming MLS Welcome without collapsing protocol rejection into an exception. An applied result carries a roster delta: an Upsert has a signature key and a Remove represents an empty native signature. recognized_user_ids must contain the IDs that the caller recognizes for this group; the wrapper adds this session's local user ID when it is absent. An empty serialized Welcome is rejected before FFI.

Session::protocol_version

fn Session::protocol_version(self : Session) -> UInt16

Return the session's current protocol version.

Session::reinitialize

fn Session::reinitialize(self : Session, protocol_version~ : UInt16, group_id~ : UInt64, self_user_id~ : UInt64) -> Unit raise DaveError

Reset and initialize this session for a new group context.

This invokes libdave's session initialization again on the same native handle. libdave retains a configured external sender and uses it to create the new pending group state. The official v1.2.0 prebuilt C ABI also generates a new transient signing identity on every reinitialization, so verification state from the previous group must not be carried forward.

Session::reset

fn Session::reset(self : Session) -> Unit raise DaveError

Clear this session's MLS group state and group context.

libdave retains a configured external sender, so a later reinitialize can create pending group state without setting the sender again.

Session::set_external_sender

fn Session::set_external_sender(self : Session, external_sender : Bytes) -> Unit raise DaveError

Configure the serialized MLS external sender package.

The sender may only be changed while there is no pending or established MLS group state. Call reset first when replacing a sender, then reinitialize to create the new pending group state.

Session::set_protocol_version

fn Session::set_protocol_version(self : Session, protocol_version~ : UInt16) -> Unit raise DaveError

Set the protocol version recorded by this session. Zero selects the unencrypted protocol state; higher values must not exceed the loaded library's supported maximum.

WelcomeResult

pub(all) enum WelcomeResult {
Applied(changes~ : Array[RosterChange])
Failed(failure~ : MlsFailure)
} derive(Eq,
Debug
)

The protocol outcome of processing an MLS Welcome.

available

fn available() -> Bool

Whether the bundled native libdave runtime is available to this process.

max_supported_protocol_version

fn max_supported_protocol_version() -> UInt16

Return the maximum DAVE protocol version supported by the loaded libdave.

This returns zero when the native library is unavailable. It does not identify the libdave ABI or release version.

pairwise_verification_code

fn pairwise_verification_code(fingerprint : Bytes) -> String raise DaveError

Convert a pairwise fingerprint into DAVE's standard 45-digit verification code.

The input must contain at least 45 bytes. Additional fingerprint bytes are intentionally ignored by the standard display-code algorithm. This function only formats bytes; it does not establish their session provenance or make a current-group fingerprint persistent across a session replacement or reinitialization.

unavailable_reason

fn unavailable_reason() -> String?

Explain why native libdave support is unavailable, if it is unavailable.

Powered by MoonBit

Site sourceReport issuePackagesBuild queueSkillsStatistics

© 2026 mooncakes.io