19 KiB
Initial managed API contract
Status: initial port slice, API revision 1. No change to protobuf or media wire formats. These are shared infrastructure APIs; the client-facing API follows with the client core.
Protocol
VoiceCat.Protocol generates Voicecat.V1 protobuf messages from the existing schema.
ControlFraming.TryReadFrame(ref ReadOnlySequence<byte>, out ReadOnlySequence<byte>)
extracts a payload and advances input only when a full frame exists. Returned memory
borrows the input's lifetime. Lengths above 16 MiB throw InvalidDataException.
Empty payloads are valid. WriteFrame and WriteEnvelope target IBufferWriter<byte>;
oversized outgoing payloads throw before output is written.
ReadEnvelopesAsync(PipeReader, CancellationToken) produces parsed envelopes and
advances consumed pipe data. It does not complete or dispose the caller's reader.
Clean EOF ends enumeration; partial EOF and oversized frames throw
InvalidDataException; malformed protobuf throws InvalidProtocolBufferException.
Cancellation propagates. A connection owner must close on protocol errors or
cancellation partway through a frame; partial frame bytes may already be consumed.
Fragments are consumed as they arrive so frames larger than pipe backpressure
thresholds make progress. Stopping enumeration between envelopes preserves the next frame.
VoiceFrameHeader is an immutable value with type, flags, codec, SSRC, sequence,
and timestamp. Write(Span<byte>) writes its 20-byte big-endian representation;
TryRead accepts at least 20 bytes and preserves unknown type/flag/codec values.
Higher layers decide which values they support.
Media encryption
MediaEncryptor and MediaDecryptor each own one directional 32-byte session key
and mutable packet state. Use one owner at a time; they provide no synchronization.
Production constructs them through TlsSession media factories after its handshake.
Raw-key constructors support conformance tests.
MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>) writes
the full header plus ciphertext and 16-byte tag and returns packet length. It replaces
the supplied sequence with its own counter, starting at zero. Capacity and overlap
errors throw before reserving a counter. Reserved counters are never reused after
encryption failure. At ulong.MaxValue, encryption throws and requires a new session.
MediaDecryptor.TryDecrypt(ReadOnlySpan<byte>, Span<byte>, out VoiceFrameHeader, out int) authenticates and decrypts a complete packet. Short packets, failed tags,
replays, and packets outside the 64-packet window return false with default header
and zero bytes written. Authentication failure clears the attempted plaintext region;
structural/replay rejection leaves storage untouched. Callers must only consume
output after success. Invalid storage capacity and overlapping buffers throw.
The nonce is four zero bytes plus the big-endian header counter. All 20 header bytes are authenticated associated data. The replay window advances after authentication. The platform ChaCha20-Poly1305 implementation is preferred; BouncyCastle is used when platform support is absent. Both produce the same wire bytes. The fallback currently allocates per packet; audio and relay allocation guarantees are later checkpoints.
Dispose both objects to clear their owned key arrays and release platform crypto
resources. Use after disposal throws ObjectDisposedException.
TLS sessions
TlsSession is a single-owner, nonblocking BouncyCastle TLS 1.3 state machine.
It owns no socket or worker thread. The transport owner feeds ReceiveCiphertext,
fully drains DrainCiphertext to its socket (including partial sends), and reads
application data through ReadPlaintext. Reads and drains return a byte count and
may require repeated calls. WritePlaintext requires IsReady. Socket cancellation,
backpressure, and connection lifetime belong to the transport owner.
CreateClient(Func<string, bool>) requires an explicit certificate acceptance
callback. It receives the uppercase SHA-256 fingerprint of the leaf certificate's
DER bytes during the handshake. Returning false rejects the session before application
data or media keys are available. This is TOFU certificate pinning; there is no PKI
chain or hostname validation. The synchronous callback must have the trust decision
available; an asynchronous first-connect prompt requires a subsequent connection
after explicit acceptance. Never automatically accept or persist an unknown pin.
CreateServer(certificatePem, privateKeyPem) supports ECDSA credentials; use
ServerCredentials.CreateTlsSession() to import persisted credentials. TLS 1.2 is
rejected. Handshake completion captures two 32-byte exporter keys using label
voicecat media v1 and one-byte contexts 0 (client to server) and 1 (server to client).
BouncyCastle discards its exporter secrets after that callback. Media factories
select the correct direction for each role and require a ready session.
Create one encryptor and decryptor per connection and retain them for the connection's
lifetime: constructing a second encryptor resets its counter and would reuse nonces.
Dispose media objects separately from the TLS session. Close() queues close_notify;
drain it before disposal. On socket EOF call CompleteInput(); missing close_notify
throws IOException. TLS/protocol errors require closing the connection. Disposal
clears the session's owned exporter arrays and scratch buffer.
Persisted trust and credentials
TofuStore uses the existing UTF-8 host:port lowercase-hex-fingerprint format.
Host matching is ordinal and case sensitive, matching native behavior. Check
returns FirstConnect, Matched, or Mismatch without changing persistence.
Only explicit Pin or Remove changes the file. Pin replacement requires an
explicit caller decision; malformed files fail closed. Changes replace the file
atomically before updating memory. Use one owner per store/file.
ServerIdentity reads and writes the native 96-byte Ed25519 format:
public-key[32] || seed[32] || public-key[32]. Loading verifies both public-key
copies against the seed. Disposal clears the owned seed.
ServerCredentials.LoadOrCreate(directory, serverName) imports identity.key,
server.crt, and server.key unchanged. If all are absent it creates an ECDSA-P256
self-signed certificate and identity. If only some exist it rejects startup rather
than rotating identity. Restore the missing files. New certificates include SAN URI
urn:voicecat:identity:ed25519:<lowercase-public-key-hex>; legacy certificates are
accepted unchanged. Checking this URI against ServerHello's identity is deferred
until the managed handshake/session layer is implemented; trust currently pins the
leaf certificate. Dispose credentials after their TLS sessions are created/finished
as required by the application lifetime.
Private file writes use a same-directory temporary file, flush, and atomic replacement. On Unix new files use owner read/write permissions; Windows inherits directory ACLs. The credential directory must have one provisioning owner. PEM strings and crypto library internal copies are managed memory; owned-array clearing does not promise erasure of every runtime/library copy.
Codec and DSP
VoiceCat.Codec and VoiceCat.Dsp call the desktop voicecat_media native library
through source-generated LibraryImport. It links pinned Opus 1.5.2 and the existing
vendored RNNoise; it has no dependency on libvoicecat or its C ABI. Fixed C signatures
wrap Opus controls so P/Invoke never calls C varargs. SafeHandle owns every native
encoder, decoder, DRED parser/state, and denoiser, including failed initialization.
OpusOptions is an immutable record. Supported PCM rates are 8/12/16/24/48 kHz,
one or two interleaved channels, and integral 10/20/40/60 ms frames. These match the
current VoiceCat protocol's integer frame duration; fractional Opus frame durations
are not exposed. Low-delay application mode requires at most 20 ms. Channel capture
bandwidth is controlled separately by MaximumBandwidthHz; the production audio
clock will remain 48 kHz. Options are validated before native creation, and native
control failures throw OpusException with the libopus error code.
OpusEncoder.Encode(ReadOnlySpan<short>, Span<byte>) accepts exactly one frame
and returns encoded bytes. OpusDecoder.Decode(packet, pcm, samplesPerChannel, recoverPreviousFrame) returns samples per channel, not total interleaved samples.
An empty packet requests PLC. Passing the next packet with recoverPreviousFrame
requests in-band FEC; absence of FEC permits libopus's PLC fallback. Decode that next
packet normally afterward. Capacity/overlap errors throw before native processing.
DRED is explicit. Unsupported native builds reject DeepRedundancy = true rather
than silently disabling it. With pinned Opus 1.5.2, DRED encoding requires PCM at
16/24/48 kHz; its activity analysis cannot emit DRED at 8/12 kHz. Such configurations
are rejected. DRED packets can still be decoded at all five rates. The encoder uses
a 30 ms minimum redundancy duration because this release needs two redundancy chunks;
the old 20 ms setting produces no DRED packets. Actual redundancy remains adaptive
to bitrate, loss estimate, and activity; it is not guaranteed in every packet.
OpusDeepRedundancy.TryRecover(audioDecoder, nextPacket, pcm, samplesPerChannel, offset) parses the next packet and reconstructs a missing frame. Default offset is
one missing frame's samples per channel before the next packet's start, matching
libopus's offset convention. A packet without DRED returns false; then the owner
can try FEC/PLC. Only consume recovery output on success. Parse/native errors throw.
RnnoiseProcessor.Process(Span<short>, sampleRate) operates in place on complete
480-sample mono chunks at 48 kHz. Other rates pass through unchanged; partial chunks
at 48 kHz throw instead of leaving a tail silently untreated. Float scratch is
preallocated, and rounding/clipping matches the C++ processor. Use distinct instances
for stereo channels when the later pipeline supports stereo microphone denoising.
Noise reduction does not gate speech.
EnergyVadProcessor.Process(ReadOnlySpan<short>) compares normalized RMS against
Threshold, retains speech for HangTime, and starts closed. It uses monotonic
TimeProvider timestamps; tests inject a clock. Threshold changes are atomic; all
processing state otherwise has one owner. Codec/DSP processing methods allocate no
managed memory after initialization, verified across 1,000 combined cycles. They run
on a managed worker, never the native real-time device callback. Native device rings,
jitter, mixer, and audio scheduling remain later work.
Initial managed server
VoiceServer(directory, endpoint, allowGuests, name) owns credentials, the SQLite
store, a TCP listener and its connection tasks. EndPoint reports the actual bound
port (zero requests an ephemeral port). Dispose asynchronously to stop the listener
and wait for all connections. The CLI currently binds loopback and accepts optional
data-directory/port positional arguments.
All control traffic uses TLS 1.3 with the existing v2 protobuf. A single async loop
owns each TlsSession; handlers exchange envelopes through bounded queues.
This checkpoint caps connections at 64, queued input at 32 envelopes, queued output
at 64 envelopes, and each control payload at 64 KiB (stricter than the shared framer's
16 MiB limit). Queue exhaustion disconnects slow consumers. Handshake timeout is
15 seconds by default. Completed TLS connections use the server's media-aware reaper.
The existing VoiceServer(directory, endpoint, allowGuests, name) constructor remains
available. An overload accepts VoiceServerOptions and an optional TimeProvider.
Options configure server name, guest access, connection limit (default 64), handshake
timeout (15 seconds), idle timeout (45 seconds) and reaper interval (15 seconds).
Zero idle timeout disables reaping; active reaping requires a positive interval.
Invalid options fail before creating credentials, databases or sockets.
Authentication starts users in unprotected Lobby (id 1), subject to its capacity.
Success returns permissions, then a cloned snapshot; peers receive joined/updated/left
events. Server-authoritative text replaces supplied sender ids/timestamps, limits
bodies to 4096 UTF-8 bytes, and acknowledges valid or rejected routing. Channel text
requires membership; private text echoes to sender and recipient. Protected joins enforce
the supplied password and capacity; LeaveChannel returns to Lobby. Passwords use the
native salted, keyed BLAKE2b-256 salt_hex:hash_hex format, verified in both directions.
Channel create/edit/delete persist before broadcasting events. Administrators can manage
all channels; CanCreateTempChannel permits creation of temporary channels only. Edit with
an empty password preserves the existing hash, matching native behavior; password removal
has no v2 request representation. Lobby cannot be deleted, protected or nested. Missing
parents, tree cycles and deletion of parents with children fail without mutation. Deletion
moves members to Lobby (even if full), clearing their streams. Edits stop existing streams
so clients must negotiate the updated audio configuration. Channel names/topics/passwords
are limited to 128/4096/1024 UTF-8 bytes. Audio requires Opus, 48 kHz, mono/stereo,
500–512000 bps, integral 5/10/20/40/60 ms frames and valid application/loss/complexity.
Database v2 has no DRED column; CRUD rejects DRED rather than silently losing it on restart.
Session permissions gate kick/ban/move/mute and account operations. Only administrators can grant permissions; account-administration permission cannot grant administrator status. These two permission restrictions are stricter than the C++ oracle. Moves bypass channel passwords but respect capacity and clear streams. Server mute/deafen immediately updates encrypted routing. Kick/ban retire routing before closure and emit one LEFT with the reason. Account bans persist by username; guest bans persist by address because nicknames are not identities. Ban wire expiry is Unix milliseconds, converted to database seconds rounded up; zero means permanent. This fixes the native handler's millisecond/second mismatch. Existing sessions on the same address/account are not swept by a target-user ban.
Create/reset/delete/list accounts require administrator or CanAdminAccounts. New accounts
are non-admin. Bounded Argon2 work runs outside the server state lock; authority is checked
when accepting the operation, and cancellation is checked before password writes. Reset
and deletion affect future authentication; existing sessions retain their permissions.
Lists omit password hashes and return millisecond timestamps. Oversized lists fail instead
of truncating or exceeding the 64 KiB frame limit. Privileged responses echo request ids;
generic codes are 6 for permission denied and 3 for invalid/missing/duplicate input.
VoiceServer.MediaEndPoint exposes the bound UDP endpoint; UDP uses the same address
and port number as TCP, and ServerHello.udp_port advertises it. Successful authentication
issues a 16-byte binding token. TLS confirmation echoes an acknowledgement; a protocol-v2
bootstrap packet binds the first UDP endpoint. Tokens cannot replace an established
endpoint; reconnect to change endpoints. Invalid tokens and malformed packets are ignored.
Voice subscription, unsubscribe, stream announce/stop and stream-state signaling are implemented. Announces require subscription and support microphone, screen audio and auxiliary device streams, with at most 16 streams per user and labels up to 128 characters. Stream ids are monotonically assigned per user; SSRCs are assigned server-wide. Channel audio settings are authoritative; requested bitrate may lower the channel ceiling (nonzero requests below 500 bps fail). User updates include the actor. Stream-state updates use the authenticated sender id and ignore unknown stream ids.
Channel movement clears active streams; joining the current channel preserves them. Unsubscribe clears streams. Disconnect removes routing and retires media resources, even if no UDP traffic follows. Senders must own the SSRC and be subscribed; recipients must be subscribed, bound, in the same channel and not deafened. Server-muted senders cannot relay. Every voice packet is authenticated with the sender's directional key; the SFU reseals encoded bytes for each recipient without decoding, replacing only the sequence and ciphertext/tag. Replay rejection precedes authentication; successful authentication advances the replay window.
The UDP loop exclusively owns media crypto, endpoint mutation and packet buffers. Control handlers publish immutable routing snapshots. Crypto is created within the TLS owner loop and transferred once. A coalesced notification wakes retired-key cleanup. The synchronous fan-out core allocates zero managed bytes with platform ChaCha20-Poly1305; socket scheduling and the allocating BouncyCastle fallback are outside that guarantee. UDP keepalives are echoed for bound endpoints. Any parsed control envelope, authenticated voice from an active owned stream, or exact header-only keepalive from a bound endpoint refreshes a shared monotonic activity timestamp. Invalid media does not refresh it. The reaper sends a fatal disconnect, removes presence/routing and broadcasts one LEFT event. Valid UDP activity keeps a TCP-idle client alive. Shutdown cancels and awaits the accept, reaper, control and media loops before disposing credentials/storage.
AccountStore(path) retains the C++ schema version 2, accepts version 1 migration,
and rejects unknown revisions. Opening an existing channel table does not reseed it.
Account creation/authentication uses parameterized SQL; two password workers bound
per-store Argon2 work. Failed authentication leaves last_login unchanged. Dispose
after its operations finish. ResetPasswordAsync, DeleteAccount and ListAccounts
also expose administration to hosts. Initial administrator provisioning uses this API or
the native administration CLI; there is no automatic bootstrap account.
PasswordHasher uses strict UTF-8 without normalization and libsodium-compatible
Argon2id v19 PHC strings: 16-byte salt, 32-byte output, new-hash parameters
64 MiB memory, two iterations, parallelism one. Verification supports up to 128 MiB,
ten iterations, parallelism four and 1024 UTF-8 password bytes; malformed or excessive
hashes fail closed. Standard C++ interactive-cost accounts are preserved. These
bounds intentionally reject imported hashes above those costs. Native fixtures cover
ASCII, Unicode and embedded NUL; the database oracle verifies cross-implementation
authentication in both directions.
SQLite's MIT provider/bundle uses the pinned public-domain SourceGear SQLite build. The license audit checks that exact package version and repository identity because the native package lacks a NuGet license expression; other dependencies still require an approved permissive expression.