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

255 lines
16 KiB
Markdown

# 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 channel
joins and all admin/moderation handlers are pending.
`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. Account provisioning currently uses this API or the
existing native administration path; 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.