7.1 KiB
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.