Files
voice-cat/docs/api-dotnet.md
T
Talon 2df79cdd4c
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s
Add managed TLS interoperability and persisted credentials
2026-09-15 18:04:20 +02:00

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.