214 lines
13 KiB
Markdown
214 lines
13 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; completed TLS connections have a 60-second receive-idle timeout.
|
|
|
|
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. No UDP port or media features
|
|
are advertised; voice subscription explicitly fails until the SFU is implemented.
|
|
|
|
`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.
|