Files
voice-cat/docs/api-dotnet.md
T

19 KiB
Raw Blame History

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.

Options also configure authentication burst/refill (5 attempts / one per ten seconds). The bounded address/account limiter runs before Argon2 and survives reconnects within the process, with escalating failure backoff. Completion reports unexpected termination of listener/media/active-reaper tasks; hosts should observe it and stop on failure. The executable supports configuration/environment, local account provisioning, JSON readiness, exclusive instance locking and bounded graceful shutdown; see deployment.md.

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, 500512000 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.