dave

package
v0.29.1-dave.26 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 15, 2026 License: BSD-3-Clause Imports: 6 Imported by: 0

README

dave

Go bindings to discord/libdave, Discord's reference C/C++ implementation of the DAVE protocol (Audio/Video E2EE for voice and video calls).

Layout

  • libdave/ — vendored as a git submodule, pinned to an upstream commit. Pure C API lives at libdave/cpp/includes/dave/dave.h. Build output goes to libdave/cpp/build/ (static library + headers).
  • session.go, decryptor.go, etc. — Go types that wrap DAVESessionHandle, DAVEDecryptorHandle, and the rest of the libdave opaque handles. Written as thin passthroughs; protocol logic lives in libdave, Go owns lifecycle + channel plumbing.
  • dave.go — CGO preamble, shared includes, error mapping.

Build requirements

libdave needs OpenSSL 3 (default) and pulls mlspp via vcpkg. Our goclaw.Dockerfile installs the build toolchain in the builder stage and static-links libstdc++ so the runtime image doesn't need a C++ runtime.

See ../docs/dave.md (forthcoming) for the high-level architecture and the ordering of voice gateway opcodes 21-31.

Status

Phase 0 scaffold. No CGO code yet.

Documentation

Overview

Package dave provides Go bindings to Discord's libdave C library, which implements the DAVE (Distributed Audio/Video End-to-End Encryption) protocol used by Discord voice and video calls.

What DAVE actually does

DAVE sits on top of Discord's existing RTP session encryption. Every voice frame Discord routes through its media server is already encrypted under a per-session key (xsalsa20_poly1305 today). DAVE adds a second layer: the frame *payload* is encrypted by the sender to a key that only other call participants hold, so Discord servers relay but cannot decrypt the audio.

The key agreement under DAVE is MLS (RFC 9420). Discord acts as an external sender that delivers proposals/commits/welcomes to clients via voice-gateway opcodes 21-31. Clients derive per-sender AEAD keys via MLS-Exporter and wrap each RTP payload with AES-128-GCM using a ULEB128-encoded nonce and an 8-byte truncated auth tag. See https://daveprotocol.com for the wire format.

What this package does

It's a thin wrapper over libdave's pure-C ABI (libdave/cpp/includes/dave/dave.h). libdave carries the cryptographic and protocol heavy lifting: MLS state, ratchet derivation, AEAD frame format, codec-aware cryptor selection. This package owns CGO lifecycle, callback plumbing, and Go-native ergonomics.

Build requirements

libdave is vendored as a git submodule at discordgo/dave/libdave. Building this package requires:

  • libdave.a (static lib) at dave/libdave/cpp/build/libdave.a
  • OpenSSL 3 (or 1.1) development headers
  • libstdc++ (linked for C++ runtime; static-linked in container builds)

Run `make -C dave/libdave/cpp` (see libdave README) to build libdave.a before `go build ./...` in this fork. For container builds, the goclaw Dockerfile handles this in its builder stage.

Listen-only usage

The cartridge bots are receive-only (no outbound encryption), so the surface we actually exercise is:

sess := dave.NewSession(authID, userID, onMLSFailure)
sess.Init(version, groupID)
sess.SetExternalSender(externalSenderBytes) // from voice opcode 25
sess.ProcessWelcome(welcomeBytes, rosterIDs) // from binary opcode 26
sess.ProcessCommit(commitBytes) // from binary opcode 27
ratchet := sess.GetKeyRatchet(remoteUserID)
dec := dave.NewDecryptor()
dec.TransitionToKeyRatchet(ratchet)
plain, err := dec.Decrypt(dave.MediaAudio, encryptedOpusFrame)

Encryption paths exist but are not wired by this fork's voice receiver.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrEncryptFailed       = errors.New("dave: encryption failed")
	ErrEncryptMissingKey   = errors.New("dave: encryptor missing key ratchet")
	ErrEncryptMissingCrypt = errors.New("dave: encryptor missing cryptographic context")
	ErrEncryptTooManyTries = errors.New("dave: encryptor exceeded retry limit")

	ErrDecryptFailed       = errors.New("dave: decryption failed")
	ErrDecryptMissingKey   = errors.New("dave: decryptor missing key ratchet")
	ErrDecryptInvalidNonce = errors.New("dave: decryptor saw invalid nonce")
	ErrDecryptMissingCrypt = errors.New("dave: decryptor missing cryptographic context")
)

Sentinel errors returned by the decryptor/encryptor. Wrap with fmt.Errorf via %w at call sites that want to add context.

Functions

func InstallLogSink

func InstallLogSink(fn LogFunc)

InstallLogSink wires libdave's global log callback to the provided LogFunc. Pass nil to disable logging. Calling this more than once replaces the previous sink.

func MaxSupportedProtocolVersion

func MaxSupportedProtocolVersion() uint16

MaxSupportedProtocolVersion returns the highest DAVE protocol version the linked libdave can negotiate. Discord advertises the protocol version it wants to run at via voice opcode 24; clients must match or downgrade.

Types

type Codec

type Codec int

Codec identifies a media codec for DAVE frame handling. libdave uses the codec to select which part of the frame is plaintext (unencrypted RTP/codec headers) vs. ciphertext (the media payload + DAVE AEAD tag).

const (
	CodecUnknown Codec = C.DAVE_CODEC_UNKNOWN
	CodecOpus    Codec = C.DAVE_CODEC_OPUS
	CodecVP8     Codec = C.DAVE_CODEC_VP8
	CodecVP9     Codec = C.DAVE_CODEC_VP9
	CodecH264    Codec = C.DAVE_CODEC_H264
	CodecH265    Codec = C.DAVE_CODEC_H265
	CodecAV1     Codec = C.DAVE_CODEC_AV1
)

type CommitResult

type CommitResult struct {
	// contains filtered or unexported fields
}

CommitResult wraps a DAVECommitResultHandle. Carries the post-commit roster and failure/ignore flags. Always Close it.

func (*CommitResult) Close

func (r *CommitResult) Close()

Close releases the handle. Safe to call multiple times.

func (*CommitResult) Failed

func (r *CommitResult) Failed() bool

Failed returns true if libdave could not apply the commit.

func (*CommitResult) Ignored

func (r *CommitResult) Ignored() bool

Ignored returns true if the commit should be treated as a no-op (e.g. we were already past this epoch).

func (*CommitResult) RosterMemberIDs

func (r *CommitResult) RosterMemberIDs() []uint64

RosterMemberIDs returns the user IDs in the group after the commit applies.

func (*CommitResult) RosterMemberSignature

func (r *CommitResult) RosterMemberSignature(memberID uint64) []byte

RosterMemberSignature returns the signature bytes for a specific roster member. Returns nil if the ID is not in the roster.

type Decryptor

type Decryptor struct {
	// contains filtered or unexported fields
}

Decryptor wraps a DAVEDecryptorHandle, which is stateful per remote RTP stream. One decryptor per SSRC: Discord reuses SSRCs per-speaker, so when VoiceSpeakingUpdate tells us a new speaker's SSRC, we mint one.

TransitionToKeyRatchet installs the ratchet for this decryptor but does NOT take ownership — the caller must keep the KeyRatchet alive until the Decryptor is closed or transitioned to a different ratchet.

func NewDecryptor

func NewDecryptor() *Decryptor

NewDecryptor allocates an empty decryptor in passthrough mode. Call TransitionToKeyRatchet before Decrypt.

func (*Decryptor) Close

func (d *Decryptor) Close()

Close destroys the decryptor handle. Safe to call repeatedly.

func (*Decryptor) Decrypt

func (d *Decryptor) Decrypt(mediaType MediaType, ciphertext []byte) ([]byte, error)

Decrypt attempts to strip DAVE's AEAD envelope from ciphertext. On success returns the plaintext frame; on a passthrough frame or a key-ratchet miss returns an error (see ErrDecrypt* sentinels).

The output slice is allocated by this call; the input is not mutated.

func (*Decryptor) MaxPlaintextSize

func (d *Decryptor) MaxPlaintextSize(mediaType MediaType, ciphertextLen int) int

MaxPlaintextSize returns the upper bound on plaintext size for a given ciphertext size. Use to pre-size the output buffer for Decrypt.

func (*Decryptor) Stats

func (d *Decryptor) Stats(mediaType MediaType) DecryptorStats

Stats reports libdave's per-decryptor counters for the given media type.

func (*Decryptor) TransitionToKeyRatchet

func (d *Decryptor) TransitionToKeyRatchet(kr *KeyRatchet)

TransitionToKeyRatchet points the decryptor at a new key ratchet (e.g. after an MLS epoch change). The ratchet is not owned by the decryptor — the caller must keep the KeyRatchet alive.

func (*Decryptor) TransitionToPassthroughMode

func (d *Decryptor) TransitionToPassthroughMode(passthrough bool)

TransitionToPassthroughMode flips the decryptor between encrypted and plaintext relay. Discord uses passthrough before the group is formed and when DAVE is disabled for a call.

type DecryptorStats

type DecryptorStats struct {
	Passthrough     uint64
	DecryptSuccess  uint64
	DecryptFailure  uint64
	DecryptDuration uint64
	DecryptAttempts uint64
	MissingKey      uint64
	InvalidNonce    uint64
}

DecryptorStats mirrors the libdave DAVEDecryptorStats struct.

type Encryptor

type Encryptor struct {
	// contains filtered or unexported fields
}

Encryptor wraps a DAVEEncryptorHandle — the send-side counterpart to Decryptor. A VoiceConnection has a single outgoing audio stream, so one Encryptor per connection suffices. SetKeyRatchet installs the local sender's key ratchet (from daveSessionGetKeyRatchet for our own user ID); the encryptor does NOT take ownership, so the caller keeps the KeyRatchet alive until Close or the next SetKeyRatchet.

This file is the cartridge-gg fork's missing half: upstream wired only the receive path (Decryptor). Sending audio into a DAVE-enforced channel needs the outgoing Opus frames wrapped in DAVE's AEAD envelope, or other clients drop them as un-decryptable.

func NewEncryptor

func NewEncryptor() *Encryptor

NewEncryptor allocates an encryptor in passthrough mode. Call SetKeyRatchet (and SetPassthroughMode(false)) once the MLS group is established before Encrypt produces ciphertext.

func (*Encryptor) AssignSSRCToCodec

func (e *Encryptor) AssignSSRCToCodec(ssrc uint32, codec Codec)

AssignSSRCToCodec tells the encryptor which codec a given outgoing SSRC carries, so it knows which bytes are plaintext headers vs ciphertext payload. Must be called before Encrypt for that SSRC.

func (*Encryptor) Close

func (e *Encryptor) Close()

Close destroys the encryptor handle. Safe to call repeatedly.

func (*Encryptor) Encrypt

func (e *Encryptor) Encrypt(mediaType MediaType, ssrc uint32, frame []byte) ([]byte, error)

Encrypt wraps a plaintext media frame in DAVE's AEAD envelope for the given SSRC, returning the ciphertext frame ready for RTP transport encryption. The output slice is freshly allocated; the input is not mutated.

func (*Encryptor) HasKeyRatchet

func (e *Encryptor) HasKeyRatchet() bool

HasKeyRatchet reports whether a ratchet has been installed (i.e. we are a member of the MLS group and can encrypt). Until true, callers should send frames in the clear (passthrough), as the group isn't established.

func (*Encryptor) IsPassthroughMode

func (e *Encryptor) IsPassthroughMode() bool

IsPassthroughMode reports the current passthrough state.

func (*Encryptor) SetKeyRatchet

func (e *Encryptor) SetKeyRatchet(kr *KeyRatchet)

SetKeyRatchet installs the key ratchet used to derive per-frame AEAD keys. The ratchet is not owned by the encryptor — the caller must keep it alive.

func (*Encryptor) SetPassthroughMode

func (e *Encryptor) SetPassthroughMode(passthrough bool)

SetPassthroughMode toggles between encrypting frames and relaying them unchanged. Discord uses passthrough before the group is formed and when DAVE is disabled for a call (op21 prepare_transition to version 0).

type KeyRatchet

type KeyRatchet struct {
	// contains filtered or unexported fields
}

KeyRatchet wraps a DAVEKeyRatchetHandle. The ratchet carries the MLS exporter secret for a specific user; Decryptors consume it to derive per- frame AEAD keys.

libdave's SetKeyRatchet / TransitionToKeyRatchet functions do NOT take ownership of the ratchet, so we must keep it alive until the consuming Decryptor/Encryptor is done with it. The caller owns the lifecycle; Close is idempotent.

func (*KeyRatchet) Close

func (kr *KeyRatchet) Close()

Close destroys the ratchet handle. Must be called once the ratchet is no longer referenced by any Decryptor or Encryptor.

type LogFunc

type LogFunc func(severity LogSeverity, file string, line int, message string)

LogFunc is called once per libdave log line if InstallLogSink is used. Libdave's log callback is process-global (not per-session).

type LogSeverity

type LogSeverity int

LogSeverity mirrors DAVELoggingSeverity from libdave.

type MLSFailureFunc

type MLSFailureFunc func(source, reason string)

MLSFailureFunc is invoked when libdave reports an MLS protocol failure on a session. "Source" is the libdave component that raised the failure (e.g. "externalSender", "welcome"); "reason" is a human-readable message. Treat this as a fatal-for-the-session signal — the caller should tear down the voice connection and reconnect.

type MediaType

type MediaType int

MediaType tells libdave whether a frame is audio or video so it can pick the matching per-codec cryptor. Cartridge bots only handle audio.

const (
	MediaAudio MediaType = C.DAVE_MEDIA_TYPE_AUDIO
	MediaVideo MediaType = C.DAVE_MEDIA_TYPE_VIDEO
)

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session wraps a DAVESessionHandle, which carries the MLS state for a single voice call. libdave sessions are not documented as thread-safe; we guard with a mutex so concurrent opcode handlers (welcome arrives while a commit is being processed, etc.) don't trip over each other.

func NewSession

func NewSession(authSessionID string, onFail MLSFailureFunc) *Session

NewSession allocates a DAVESessionHandle and registers the failure callback. authSessionID is used by libdave to scope persistent key lifetimes — pass a stable per-voice-session string (e.g. the voice gateway session ID). onFail is invoked from a libdave thread; dispatch asynchronously if you need to touch heavy state.

func (*Session) Destroy

func (s *Session) Destroy()

Destroy releases the underlying handle and unregisters the callback slot. Safe to call multiple times.

func (*Session) GetKeyRatchet

func (s *Session) GetKeyRatchet(userID string) *KeyRatchet

GetKeyRatchet returns a KeyRatchet handle for a specific user in the group. The ratchet is consumed by a Decryptor (or Encryptor) to produce per-frame keys via MLS-Exporter. Caller must Close the returned KeyRatchet.

func (*Session) Init

func (s *Session) Init(protocolVersion uint16, groupID uint64, selfUserID string)

Init sets the protocol version, MLS group ID, and the local user ID on the session. Must be called after NewSession and before any process* call.

func (*Session) LastEpochAuthenticator

func (s *Session) LastEpochAuthenticator() []byte

LastEpochAuthenticator returns the authenticator bytes for the last MLS epoch. Useful for debugging group state divergence.

func (*Session) MarshalledKeyPackage

func (s *Session) MarshalledKeyPackage() []byte

MarshalledKeyPackage returns the MLS key package to send to Discord in response to opcode 26 (DAVE_MLS_KEY_PACKAGE). Caller owns the returned slice (it is copied out of libdave-allocated memory).

func (*Session) ProcessCommit

func (s *Session) ProcessCommit(commit []byte) *CommitResult

ProcessCommit feeds a commit message from Discord (opcode 27 with optype=commit). Returns a CommitResult the caller must Close.

func (*Session) ProcessProposals

func (s *Session) ProcessProposals(proposals []byte, recognizedUserIDs []string) []byte

ProcessProposals feeds proposals received from Discord (opcode 27 with optype=proposals) and returns the commit/welcome bytes we must send back. recognizedUserIDs is the set of user IDs our client trusts to be in the voice channel (i.e. users we've seen via VoiceStateUpdate). libdave uses these to decide whether to accept the proposal batch.

func (*Session) ProcessWelcome

func (s *Session) ProcessWelcome(welcome []byte, recognizedUserIDs []string) *WelcomeResult

ProcessWelcome feeds a welcome message (opcode 26) and returns a WelcomeResult the caller must Close.

func (*Session) ProtocolVersion

func (s *Session) ProtocolVersion() uint16

ProtocolVersion returns the version currently active on the session.

func (*Session) Reset

func (s *Session) Reset()

Reset clears per-epoch state without destroying the session handle. Useful between voice reconnects on the same call.

func (*Session) SetExternalSender

func (s *Session) SetExternalSender(externalSender []byte)

SetExternalSender installs Discord's external sender credentials. Received as a binary payload from voice opcode 25 (DAVE_MLS_EXTERNAL_SENDER).

func (*Session) SetProtocolVersion

func (s *Session) SetProtocolVersion(v uint16)

SetProtocolVersion updates the negotiated protocol version mid-session (Discord advertises version changes via voice opcode 24).

type WelcomeResult

type WelcomeResult struct {
	// contains filtered or unexported fields
}

WelcomeResult wraps a DAVEWelcomeResultHandle. Carries the initial roster after we joined the MLS group. Always Close it.

func (*WelcomeResult) Close

func (r *WelcomeResult) Close()

Close releases the handle. Safe to call multiple times.

func (*WelcomeResult) RosterMemberIDs

func (r *WelcomeResult) RosterMemberIDs() []uint64

RosterMemberIDs returns the user IDs that form the initial group roster.

func (*WelcomeResult) RosterMemberSignature

func (r *WelcomeResult) RosterMemberSignature(memberID uint64) []byte

RosterMemberSignature returns the signature bytes for a specific roster member. Returns nil if the ID is not in the roster.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL