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 ¶
- Variables
- func InstallLogSink(fn LogFunc)
- func MaxSupportedProtocolVersion() uint16
- type Codec
- type CommitResult
- type Decryptor
- func (d *Decryptor) Close()
- func (d *Decryptor) Decrypt(mediaType MediaType, ciphertext []byte) ([]byte, error)
- func (d *Decryptor) MaxPlaintextSize(mediaType MediaType, ciphertextLen int) int
- func (d *Decryptor) Stats(mediaType MediaType) DecryptorStats
- func (d *Decryptor) TransitionToKeyRatchet(kr *KeyRatchet)
- func (d *Decryptor) TransitionToPassthroughMode(passthrough bool)
- type DecryptorStats
- type Encryptor
- func (e *Encryptor) AssignSSRCToCodec(ssrc uint32, codec Codec)
- func (e *Encryptor) Close()
- func (e *Encryptor) Encrypt(mediaType MediaType, ssrc uint32, frame []byte) ([]byte, error)
- func (e *Encryptor) HasKeyRatchet() bool
- func (e *Encryptor) IsPassthroughMode() bool
- func (e *Encryptor) SetKeyRatchet(kr *KeyRatchet)
- func (e *Encryptor) SetPassthroughMode(passthrough bool)
- type KeyRatchet
- type LogFunc
- type LogSeverity
- type MLSFailureFunc
- type MediaType
- type Session
- func (s *Session) Destroy()
- func (s *Session) GetKeyRatchet(userID string) *KeyRatchet
- func (s *Session) Init(protocolVersion uint16, groupID uint64, selfUserID string)
- func (s *Session) LastEpochAuthenticator() []byte
- func (s *Session) MarshalledKeyPackage() []byte
- func (s *Session) ProcessCommit(commit []byte) *CommitResult
- func (s *Session) ProcessProposals(proposals []byte, recognizedUserIDs []string) []byte
- func (s *Session) ProcessWelcome(welcome []byte, recognizedUserIDs []string) *WelcomeResult
- func (s *Session) ProtocolVersion() uint16
- func (s *Session) Reset()
- func (s *Session) SetExternalSender(externalSender []byte)
- func (s *Session) SetProtocolVersion(v uint16)
- type WelcomeResult
Constants ¶
This section is empty.
Variables ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
const ( LogVerbose LogSeverity = C.DAVE_LOGGING_SEVERITY_VERBOSE LogInfo LogSeverity = C.DAVE_LOGGING_SEVERITY_INFO LogWarning LogSeverity = C.DAVE_LOGGING_SEVERITY_WARNING LogError LogSeverity = C.DAVE_LOGGING_SEVERITY_ERROR LogNone LogSeverity = C.DAVE_LOGGING_SEVERITY_NONE )
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 ¶
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 ¶
LastEpochAuthenticator returns the authenticator bytes for the last MLS epoch. Useful for debugging group state divergence.
func (*Session) MarshalledKeyPackage ¶
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 ¶
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 ¶
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 ¶
SetExternalSender installs Discord's external sender credentials. Received as a binary payload from voice opcode 25 (DAVE_MLS_EXTERNAL_SENDER).
func (*Session) SetProtocolVersion ¶
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.