293 lines
19 KiB
Markdown
293 lines
19 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 5/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,
|
||
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.
|