Files

317 lines
21 KiB
Markdown
Raw Permalink Normal View 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.
`ServerProfile` is the app-facing saved-server model shared by platform clients. Construct
profiles with `ServerProfile.Create`, which trims values and requires a host, nonzero port,
and username for account authentication. A profile contains only its stable ID, endpoint,
authentication mode, username or guest nickname. It deliberately has no password field.
Platform clients own any secret storage, such as macOS Keychain integration.
`ServerProfileStore` serializes profiles as camel-case JSON with string authentication
values. Missing, malformed and unreadable files load as an empty list; invalid individual
profiles are discarded. Saving filters invalid entries and replaces the file through a
same-directory temporary file. Use one owner per store/file and do not treat it as a
credential store.
`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. 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 is covered by canonical vectors. 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. Managed capture/playback
rings compensate independent clock drift around a configurable 20/40/60 ms target. Receive
jitter is duration-aware, reserves one codec frame for DRED/FEC look-ahead, and decodes each
channel packet at its actual duration before the fixed 20 ms mixer stage.
## 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.
2026-09-22 16:54:49 +02:00
Database v4 persists DRED and `AudioConfig.packet_loss_mode` with the rest of the channel audio
configuration. Opening older databases adds missing columns with disabled/manual defaults. The
mode's zero value is manual for wire and database compatibility. Automatic Fast/Balanced/Stable
modes use 3/10/30-second sender-uplink
windows respectively. The server derives loss only from authenticated media counters, requires at
least 20 expected packets, caps the applied Opus hint at 30%, and sends changed values to capable
clients with `PacketLossUpdate`. The managed audio worker applies the mutable encoder control;
device callbacks remain free of networking, allocation, and synchronization. The stored manual
percentage is retained as the bootstrap value while an automatic window fills.
Support is advertised as `adaptive-packet-loss` in both hello feature lists. Older senders keep
using the bootstrap value, and edits from older administrators preserve an existing automatic mode.
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 intentional managed-server policy. 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.
2026-09-22 14:11:36 +02:00
`AccountStore(path)` uses schema version 3 and accepts version 1 and 2 migrations,
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 interactive-cost Argon2id 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.