Add managed TLS interoperability and persisted credentials
.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

This commit is contained in:
2026-09-15 18:04:20 +02:00
parent b76181d9fb
commit 2df79cdd4c
17 changed files with 962 additions and 9 deletions
+1 -1
View File
@@ -53,4 +53,4 @@ jobs:
./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json ./build/dev/bin/voicecat-dotnet-oracle build/dev/cpp-wire.json
diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json diff -u dotnet/tests/VoiceCat.Tests/Fixtures/cpp-wire.json build/dev/cpp-wire.json
dotnet restore dotnet/VoiceCat.slnx --locked-mode dotnet restore dotnet/VoiceCat.slnx --locked-mode
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore VOICECAT_TLS_ORACLE="$PWD/build/dev/bin/voicecat-dotnet-tls-oracle" dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
+13
View File
@@ -10,6 +10,19 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done (2026-09-15): TLS exporter interoperability and persisted credentials.** Foundation commit
`b76181d` pushed to `origin/dotnet/foundations`. Added a nonblocking managed TLS 1.3
session with certificate acceptance gate and directional media factories. Managed
loopback and C++ interoperability pass; exporter keys are captured inside BouncyCastle's
handshake callback before its exporter secrets are destroyed. The C++ TLS oracle
authenticates encrypted challenges in both directions against the existing mbedTLS
context. Added explicit persisted TOFU pins, compatible Ed25519 identity/PEM import,
new certificate identity SAN, and rejection of incomplete credential sets.
**Verified:** 42/42 managed tests with the native TLS oracle enabled; native dev build
and 29/29 CTest tests green; 16 permissive package licenses verified. Complete socket
orchestration and managed server/client state remain pending.
**Next:** Phase 3 codec/DSP wrappers and native packaging.
- **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`. - **Done (2026-09-15): Initial .NET wire/crypto port** on `dotnet/foundations`, from `cs-port`.
Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers, Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers,
directional media encryption/decryption, and xUnit conformance tests. Both platform directional media encryption/decryption, and xUnit conformance tests. Both platform
+62 -2
View File
@@ -31,8 +31,8 @@ Higher layers decide which values they support.
`MediaEncryptor` and `MediaDecryptor` each own one directional 32-byte session key `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. and mutable packet state. Use one owner at a time; they provide no synchronization.
Production constructs them from TLS exporter keys when TLS is implemented. Raw-key Production constructs them through `TlsSession` media factories after its handshake.
constructors support conformance tests and the future TLS integration. Raw-key constructors support conformance tests.
`MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>)` writes `MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan<byte>, Span<byte>)` writes
the full header plus ciphertext and 16-byte tag and returns packet length. It replaces the full header plus ciphertext and 16-byte tag and returns packet length. It replaces
@@ -55,3 +55,63 @@ allocates per packet; audio and relay allocation guarantees are later checkpoint
Dispose both objects to clear their owned key arrays and release platform crypto Dispose both objects to clear their owned key arrays and release platform crypto
resources. Use after disposal throws `ObjectDisposedException`. 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.
+13 -1
View File
@@ -1,6 +1,8 @@
# Porting VoiceCat to pure .NET / C# # Porting VoiceCat to pure .NET / C#
**Status:** initial wire/crypto slice implemented under `dotnet/`; later phases remain planned. **Status:** wire/media crypto and TLS/exporter foundations implemented under `dotnet/`,
including C++ interoperability, persisted TOFU, and compatible server credentials.
Codec/audio, managed server/client state, and UI phases remain planned.
See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps. See `dotnet/README.md`, `docs/api-dotnet.md`, and `PROGRESS.md` for verification and next steps.
**Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on. **Target runtime:** .NET 10 LTS (in-service to Nov 2028), with .NET 11 as the follow-on.
**Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the **Scope:** replace the C++ core (`libvoicecat`), the C++ server, the C++ `vccli`, and the
@@ -676,6 +678,16 @@ not assumed.
**Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives **Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives
matching media keys, and pins the leaf fingerprint. matching media keys, and pins the leaf fingerprint.
**Checkpoint (2026-09-15):** implemented nonblocking managed TLS, handshake-time
exporters, explicit certificate acceptance, persisted TOFU, and native-compatible
credentials. The C++ TLS oracle authenticates a media challenge in both directions
over an actual socket, proving exporter compatibility. Tests also cover managed
fragmented loopback, first-connect acceptance, changed-pin rejection, TLS 1.2 rejection,
close_notify/abrupt EOF, restart persistence, and import of C++ credential files.
Socket orchestration remains a transport-owner responsibility; the complete managed
server and client are later phases. See `dotnet/README.md` for the required native
interoperability test command.
--- ---
### Phase 3 — Codec + DSP (est. 1 week) ### Phase 3 — Codec + DSP (est. 1 week)
+10
View File
@@ -49,6 +49,16 @@ This is a known limitation of the current design. Closing it properly requires b
Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned Ed25519 key into the TLS cert (e.g. as a SubjectAltName or extension), which is a planned
future improvement. Until then, clients display both values but gate on the cert fingerprint. future improvement. Until then, clients display both values but gate on the cert fingerprint.
**Managed rewrite checkpoint:** `dotnet/` uses nonblocking BouncyCastle TLS 1.3 and
captures directional exporters during handshake completion. Its client requires an
explicit leaf-fingerprint acceptance callback; PKI validation remains unimplemented.
New managed server certificates include the Ed25519 public key in SAN URI
`urn:voicecat:identity:ed25519:<lowercase-public-key-hex>`. Existing C++ credentials
are imported unchanged. Verifying that URI against the declared ServerHello identity
is still deferred to the managed session layer; leaf-certificate TOFU remains the
trust gate. Missing members of a persisted credential set cause startup rejection
rather than automatic identity rotation. See [api-dotnet.md](api-dotnet.md).
Client certificates are reserved for a future "key-based identity" option (see roadmap) but Client certificates are reserved for a future "key-based identity" option (see roadmap) but
are not required in v1. are not required in v1.
+22 -5
View File
@@ -1,7 +1,8 @@
# VoiceCat .NET rewrite # VoiceCat .NET rewrite
The first slice targets .NET 10: protobuf, control framing, voice headers, and media The first slice targets .NET 10: protobuf, control framing, voice headers, and media
encryption. TLS, server/client state, audio, and UI migration are next. The existing encryption, TLS 1.3, persisted TOFU pins, and server credentials. Server/client state,
audio, and UI migration are next. The existing
C++ implementation remains the conformance oracle. C++ implementation remains the conformance oracle.
From the repository root: From the repository root:
@@ -46,9 +47,25 @@ and 65536. Keys contain bytes 031; payload bytes count upward from zero. The
20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960. 20-byte header has type 1, marker flag, codec 0, SSRC `0xcafebabe`, timestamp 960.
Both managed crypto backends must match these bytes. Both managed crypto backends must match these bytes.
## TLS interoperability
The optional TLS oracle uses the existing mbedTLS context and libsodium media crypto.
The test authenticates an encrypted challenge in both directions, proving exporter
compatibility without sending raw keys. It also loads the C++ server's credential files.
```powershell
cmake --build --preset dev --target voicecat-dotnet-tls-oracle
$env:VOICECAT_TLS_ORACLE = (Resolve-Path build/dev/bin/voicecat-dotnet-tls-oracle.exe).Path
dotnet test dotnet/VoiceCat.slnx -c Release --no-restore
```
On Linux/macOS, set `VOICECAT_TLS_ORACLE` to the absolute executable path without
`.exe`. Without that variable, only this native interoperability test is skipped;
managed TLS loopback, rejection, persistence, and wire tests still run. CI's C++
conformance job requires the native test. See `docs/api-dotnet.md` for ownership
and certificate acceptance requirements.
## Next checkpoint ## Next checkpoint
Prove BouncyCastle TLS 1.3 loopback and interoperability with the C++ mbedTLS server, Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan.
including exporter label `voicecat media v1`, one-byte direction contexts 0/1, The managed server follows, tested first with the existing C++ CLI.
and TLS leaf certificate fingerprint pinning. Then implement the managed server,
tested first with the existing C++ CLI.
+5
View File
@@ -2,3 +2,8 @@ add_executable(voicecat-dotnet-oracle main.cpp)
target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat) target_link_libraries(voicecat-dotnet-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src) target_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20) target_compile_features(voicecat-dotnet-oracle PRIVATE cxx_std_20)
add_executable(voicecat-dotnet-tls-oracle tls.cpp)
target_link_libraries(voicecat-dotnet-tls-oracle PRIVATE voicecat::voicecat)
target_include_directories(voicecat-dotnet-tls-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
target_compile_features(voicecat-dotnet-tls-oracle PRIVATE cxx_std_20)
+76
View File
@@ -0,0 +1,76 @@
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
using socket_type = SOCKET;
static void close_socket(socket_type socket) { closesocket(socket); }
#else
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unistd.h>
using socket_type = int;
static void close_socket(socket_type socket) { close(socket); }
#endif
#include "crypto/crypto.h"
#include "net/voice_frame.h"
#include <filesystem>
#include <fstream>
#include <iostream>
static bool transfer(voicecat::crypto::TlsContext& tls, uint8_t* data, size_t size, bool writing) {
while (size != 0) {
int count = writing ? tls.write(data, size) : tls.read(data, size);
if (count <= 0) return false;
data += count;
size -= count;
}
return true;
}
int main(int argc, char** argv) {
if (argc != 2 || sodium_init() < 0) return 1;
#ifdef _WIN32
WSADATA data{};
if (WSAStartup(MAKEWORD(2, 2), &data) != 0) return 1;
#endif
try {
auto certificate = voicecat::crypto::ServerCert::generate("dotnet-tls-oracle");
auto directory = std::filesystem::path(argv[1]);
socket_type listener = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (bind(listener, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1;
socklen_t length = sizeof(address);
if (getsockname(listener, reinterpret_cast<sockaddr*>(&address), &length) != 0) return 1;
certificate.save(directory / "server.crt", directory / "server.key");
voicecat::crypto::ServerIdentity::generate().save(directory / "identity.key");
std::ofstream(directory / "port.txt") << ntohs(address.sin_port);
socket_type peer = accept(listener, nullptr, nullptr);
close_socket(listener);
if (peer == static_cast<socket_type>(-1)) return 1;
voicecat::crypto::TlsContext tls(voicecat::crypto::TlsContext::Role::Server, &certificate);
tls.set_read_timeout(10000);
std::string error;
if (!tls.handshake(static_cast<int>(peer), error)) { std::cerr << error; return 1; }
auto sender = voicecat::crypto::SodiumMediaCrypto::derive_send(tls, false);
auto receiver = voicecat::crypto::SodiumMediaCrypto::derive_recv(tls, false);
if (!sender || !receiver) return 1;
voicecat::net::VoiceFrame header;
header.ssrc = 42;
header.seq = sender->peek_send_counter();
std::array<uint8_t, 41> packet{};
voicecat::net::serialize_header(header, packet.data());
const std::array<uint8_t, 5> message{ 'h', 'e', 'l', 'l', 'o' };
if (sender->seal(message.data(), message.size(), packet.data(), 20, packet.data() + 20, 21) != 21) return 1;
if (!transfer(tls, packet.data(), packet.size(), true) || !transfer(tls, packet.data(), packet.size(), false)) return 1;
std::array<uint8_t, 5> recovered{};
if (receiver->open(packet.data() + 20, 21, packet.data(), 20, recovered.data(), recovered.size()) != 5 || recovered != message) return 1;
uint8_t acknowledgement = 1;
if (!transfer(tls, &acknowledgement, 1, true)) return 1;
return 0;
} catch (const std::exception& error) {
std::cerr << error.what();
return 1;
}
}
@@ -0,0 +1,22 @@
namespace VoiceCat.Crypto;
internal static class PrivateFiles
{
public static void Write(string path, ReadOnlySpan<byte> data)
{
string destination = Path.GetFullPath(path);
string temporary = destination + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None };
if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
using (var stream = new FileStream(temporary, options))
{
stream.Write(data);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, destination, overwrite: true);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
}
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace VoiceCat.Crypto;
public sealed class ServerCredentials : IDisposable
{
private readonly X509Certificate2 certificate;
private bool disposed;
private ServerCredentials(ServerIdentity identity, X509Certificate2 certificate)
{
Identity = identity;
this.certificate = certificate;
}
public ServerIdentity Identity { get; }
public string CertificateFingerprint => Convert.ToHexString(SHA256.HashData(certificate.RawData));
public static ServerCredentials LoadOrCreate(string directory, string serverName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
Directory.CreateDirectory(directory);
string identityPath = Path.Combine(directory, "identity.key");
string certificatePath = Path.Combine(directory, "server.crt");
string keyPath = Path.Combine(directory, "server.key");
bool hasIdentity = File.Exists(identityPath);
bool hasCertificate = File.Exists(certificatePath);
bool hasKey = File.Exists(keyPath);
if (hasIdentity && hasCertificate && hasKey)
{
var identity = ServerIdentity.Load(identityPath);
try { return new(identity, X509Certificate2.CreateFromPemFile(certificatePath, keyPath)); }
catch { identity.Dispose(); throw; }
}
if (hasIdentity || hasCertificate || hasKey)
throw new InvalidDataException("Server credentials are incomplete; restore the missing files before starting.");
var generated = ServerIdentity.Generate();
try
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var name = new X500DistinguishedNameBuilder();
name.AddCommonName(serverName);
var request = new CertificateRequest(name.Build(), key, HashAlgorithmName.SHA256);
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true));
var san = new SubjectAlternativeNameBuilder();
san.AddUri(new Uri("urn:voicecat:identity:ed25519:" + Convert.ToHexString(generated.PublicKey).ToLowerInvariant()));
request.CertificateExtensions.Add(san.Build());
using var created = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(10));
string certificatePem = created.ExportCertificatePem();
string privateKeyPem = key.ExportPkcs8PrivateKeyPem();
generated.Save(identityPath);
PrivateFiles.Write(certificatePath, Encoding.UTF8.GetBytes(certificatePem));
PrivateFiles.Write(keyPath, Encoding.UTF8.GetBytes(privateKeyPem));
return new(generated, X509Certificate2.CreateFromPem(certificatePem, privateKeyPem));
}
catch { generated.Dispose(); throw; }
}
public TlsSession CreateTlsSession()
{
ObjectDisposedException.ThrowIf(disposed, this);
using var key = certificate.GetECDsaPrivateKey() ?? throw new InvalidDataException("Server TLS certificate requires an ECDSA key.");
return TlsSession.CreateServer(certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem());
}
public void Dispose()
{
if (disposed) return;
disposed = true;
Identity.Dispose();
certificate.Dispose();
}
}
@@ -0,0 +1,58 @@
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto.Parameters;
namespace VoiceCat.Crypto;
public sealed class ServerIdentity : IDisposable
{
private readonly byte[] seed;
private readonly byte[] publicKey;
private bool disposed;
private ServerIdentity(byte[] seed)
{
this.seed = seed;
publicKey = new Ed25519PrivateKeyParameters(seed, 0).GeneratePublicKey().GetEncoded();
}
public byte[] PublicKey => (byte[])publicKey.Clone();
public string Fingerprint => Convert.ToHexString(SHA256.HashData(publicKey));
public static ServerIdentity Generate() => new(RandomNumberGenerator.GetBytes(32));
public static ServerIdentity Load(string path)
{
byte[] data = File.ReadAllBytes(path);
try
{
if (data.Length != 96) throw new InvalidDataException("Server identity must contain 96 bytes.");
var identity = new ServerIdentity(data.AsSpan(32, 32).ToArray());
if (!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(0, 32)) ||
!CryptographicOperations.FixedTimeEquals(identity.publicKey, data.AsSpan(64, 32)))
{
identity.Dispose();
throw new InvalidDataException("Server identity public key does not match its seed.");
}
return identity;
}
finally { CryptographicOperations.ZeroMemory(data); }
}
public void Save(string path)
{
ObjectDisposedException.ThrowIf(disposed, this);
byte[] data = new byte[96];
publicKey.CopyTo(data, 0);
seed.CopyTo(data, 32);
publicKey.CopyTo(data, 64);
try { PrivateFiles.Write(path, data); }
finally { CryptographicOperations.ZeroMemory(data); }
}
public void Dispose()
{
if (disposed) return;
disposed = true;
CryptographicOperations.ZeroMemory(seed);
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Security.Cryptography;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Tls;
using Org.BouncyCastle.Tls.Crypto;
using Org.BouncyCastle.Tls.Crypto.Impl.BC;
namespace VoiceCat.Crypto;
public sealed class TlsSession : IDisposable
{
private readonly TlsProtocol protocol;
private readonly bool isClient;
private readonly byte[] scratch = new byte[16384];
private byte[]? clientToServerKey;
private byte[]? serverToClientKey;
private bool disposed;
private TlsSession(TlsProtocol protocol, bool isClient)
{
this.protocol = protocol;
this.isClient = isClient;
}
public bool IsReady => !disposed && clientToServerKey is not null && serverToClientKey is not null && !protocol.IsClosed;
public string? PeerCertificateFingerprint { get; private set; }
public int PendingCiphertextBytes => protocol.GetAvailableOutputBytes();
public void Close()
{
ObjectDisposedException.ThrowIf(disposed, this);
protocol.Close();
}
public void CompleteInput()
{
ObjectDisposedException.ThrowIf(disposed, this);
protocol.CloseInput();
}
public static TlsSession CreateClient(Func<string, bool> acceptCertificate)
{
ArgumentNullException.ThrowIfNull(acceptCertificate);
var protocol = new TlsClientProtocol();
var session = new TlsSession(protocol, true);
protocol.Connect(new ClientPeer(session, acceptCertificate));
return session;
}
public static TlsSession CreateServer(string certificatePem, string privateKeyPem)
{
ArgumentException.ThrowIfNullOrWhiteSpace(certificatePem);
ArgumentException.ThrowIfNullOrWhiteSpace(privateKeyPem);
var protocol = new TlsServerProtocol();
var session = new TlsSession(protocol, false);
protocol.Accept(new ServerPeer(session, certificatePem, privateKeyPem));
return session;
}
public void ReceiveCiphertext(ReadOnlySpan<byte> input)
{
ObjectDisposedException.ThrowIf(disposed, this);
while (!input.IsEmpty)
{
int count = Math.Min(input.Length, scratch.Length);
input[..count].CopyTo(scratch);
protocol.OfferInput(scratch, 0, count);
input = input[count..];
}
}
public int DrainCiphertext(Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
int count = protocol.ReadOutput(scratch, 0, Math.Min(output.Length, scratch.Length));
scratch.AsSpan(0, count).CopyTo(output);
return count;
}
public int ReadPlaintext(Span<byte> output)
{
ObjectDisposedException.ThrowIf(disposed, this);
int count = protocol.ReadInput(scratch, 0, Math.Min(output.Length, scratch.Length));
scratch.AsSpan(0, count).CopyTo(output);
CryptographicOperations.ZeroMemory(scratch.AsSpan(0, count));
return count;
}
public void WritePlaintext(ReadOnlySpan<byte> input)
{
RequireReady();
protocol.WriteApplicationData(input);
}
public MediaEncryptor CreateMediaEncryptor()
{
byte[] key = ExportMediaKey(isClient ? (byte)0 : (byte)1);
try { return new(key); }
finally { CryptographicOperations.ZeroMemory(key); }
}
public MediaDecryptor CreateMediaDecryptor()
{
byte[] key = ExportMediaKey(isClient ? (byte)1 : (byte)0);
try { return new(key); }
finally { CryptographicOperations.ZeroMemory(key); }
}
internal byte[] ExportMediaKey(byte direction)
{
RequireReady();
ArgumentOutOfRangeException.ThrowIfGreaterThan(direction, (byte)1);
return (byte[])(direction == 0 ? clientToServerKey! : serverToClientKey!).Clone();
}
private void CompleteHandshake(TlsContext context)
{
// BouncyCastle destroys exporter secrets after this callback returns.
clientToServerKey = context.ExportKeyingMaterial("voicecat media v1", [0], 32);
serverToClientKey = context.ExportKeyingMaterial("voicecat media v1", [1], 32);
}
private void RequireReady()
{
ObjectDisposedException.ThrowIf(disposed, this);
if (!IsReady) throw new InvalidOperationException("TLS handshake has not completed or the session is closed.");
}
public void Dispose()
{
if (disposed) return;
disposed = true;
try { protocol.Close(); }
finally
{
if (clientToServerKey is not null) CryptographicOperations.ZeroMemory(clientToServerKey);
if (serverToClientKey is not null) CryptographicOperations.ZeroMemory(serverToClientKey);
CryptographicOperations.ZeroMemory(scratch);
}
}
private sealed class ClientPeer(TlsSession session, Func<string, bool> acceptCertificate)
: DefaultTlsClient(new BcTlsCrypto())
{
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
protected override int[] GetSupportedCipherSuites() => CipherSuites;
public override TlsAuthentication GetAuthentication() => new Authentication(session, acceptCertificate);
public override void NotifyHandshakeComplete()
{
base.NotifyHandshakeComplete();
session.CompleteHandshake(m_context);
}
}
private sealed class Authentication(TlsSession session, Func<string, bool> acceptCertificate) : TlsAuthentication
{
public void NotifyServerCertificate(TlsServerCertificate serverCertificate)
{
var chain = serverCertificate.Certificate.GetCertificateList();
if (chain.Length == 0) throw new TlsFatalAlert(AlertDescription.bad_certificate);
string fingerprint = Convert.ToHexString(SHA256.HashData(chain[0].GetEncoded()));
session.PeerCertificateFingerprint = fingerprint;
if (!acceptCertificate(fingerprint)) throw new TlsFatalAlert(AlertDescription.bad_certificate);
}
public TlsCredentials? GetClientCredentials(Org.BouncyCastle.Tls.CertificateRequest certificateRequest) => null;
}
private sealed class ServerPeer : DefaultTlsServer
{
private readonly TlsSession session;
private readonly byte[] certificateDer;
private readonly AsymmetricKeyParameter privateKey;
public ServerPeer(TlsSession session, string certificatePem, string privateKeyPem) : base(new BcTlsCrypto())
{
this.session = session;
using var certificate = System.Security.Cryptography.X509Certificates.X509Certificate2.CreateFromPem(certificatePem);
certificateDer = certificate.RawData;
using var reader = new StringReader(privateKeyPem);
privateKey = (AsymmetricKeyParameter)new PemReader(reader).ReadObject();
if (privateKey is not Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters)
throw new ArgumentException("Server TLS credentials require an ECDSA key.", nameof(privateKeyPem));
}
protected override ProtocolVersion[] GetSupportedVersions() => [ProtocolVersion.TLSv13];
protected override int[] GetSupportedCipherSuites() => CipherSuites;
public override TlsCredentials GetCredentials()
{
var certificate = new Certificate([], [new CertificateEntry(Crypto.CreateCertificate(certificateDer), null)]);
return new BcDefaultTlsCredentialedSigner(new TlsCryptoParameters(m_context), (BcTlsCrypto)Crypto,
privateKey, certificate, new SignatureAndHashAlgorithm(Org.BouncyCastle.Tls.HashAlgorithm.sha256, SignatureAlgorithm.ecdsa));
}
public override void NotifyHandshakeComplete()
{
base.NotifyHandshakeComplete();
session.CompleteHandshake(m_context);
}
}
private static int[] CipherSuites =>
[
CipherSuite.TLS_AES_128_GCM_SHA256,
CipherSuite.TLS_AES_256_GCM_SHA384,
CipherSuite.TLS_CHACHA20_POLY1305_SHA256
];
}
+72
View File
@@ -0,0 +1,72 @@
using System.Text;
namespace VoiceCat.Crypto;
public enum TofuStatus { FirstConnect, Matched, Mismatch }
public sealed class TofuStore
{
private readonly string path;
private readonly Dictionary<string, string> pins = new(StringComparer.Ordinal);
public TofuStore(string path)
{
this.path = Path.GetFullPath(path);
if (!File.Exists(this.path)) return;
foreach (string line in File.ReadLines(this.path))
{
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue;
string[] parts = line.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2) throw new InvalidDataException("Malformed TOFU pin entry.");
pins[parts[0]] = NormalizeFingerprint(parts[1]);
}
}
public TofuStatus Check(string host, ushort port, string fingerprint)
{
string key = Endpoint(host, port);
string normalized = NormalizeFingerprint(fingerprint);
return !pins.TryGetValue(key, out var pin) ? TofuStatus.FirstConnect :
pin == normalized ? TofuStatus.Matched : TofuStatus.Mismatch;
}
public void Pin(string host, ushort port, string fingerprint)
{
string key = Endpoint(host, port);
string value = NormalizeFingerprint(fingerprint);
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal) { [key] = value };
Save(updated);
pins[key] = value;
}
public void Remove(string host, ushort port)
{
string key = Endpoint(host, port);
var updated = new Dictionary<string, string>(pins, StringComparer.Ordinal);
updated.Remove(key);
Save(updated);
pins.Remove(key);
}
private void Save(Dictionary<string, string> updated)
{
string contents = string.Concat(updated.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key} {pair.Value}\n"));
PrivateFiles.Write(path, Encoding.UTF8.GetBytes(contents));
}
private static string Endpoint(string host, ushort port)
{
ArgumentException.ThrowIfNullOrWhiteSpace(host);
if (host.Any(char.IsWhiteSpace)) throw new ArgumentException("Host cannot contain whitespace.", nameof(host));
ArgumentOutOfRangeException.ThrowIfZero(port);
return $"{host}:{port}";
}
private static string NormalizeFingerprint(string fingerprint)
{
ArgumentNullException.ThrowIfNull(fingerprint);
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit))
throw new InvalidDataException("TLS certificate fingerprints must contain 64 hexadecimal characters.");
return fingerprint.ToLowerInvariant();
}
}
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Formats.Asn1;
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public class IdentityTests
{
[Fact]
public void CredentialsSurviveRestartAndBindIdentityIntoCertificate()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-credentials-" + Guid.NewGuid());
try
{
string identityFingerprint, certificateFingerprint;
using (var credentials = ServerCredentials.LoadOrCreate(directory, "Server, with punctuation"))
{
identityFingerprint = credentials.Identity.Fingerprint;
certificateFingerprint = credentials.CertificateFingerprint;
using var tls = credentials.CreateTlsSession();
Assert.False(tls.IsReady);
byte[] identity = File.ReadAllBytes(Path.Combine(directory, "identity.key"));
Assert.Equal(96, identity.Length);
Assert.Equal(identity[..32], identity[64..]);
using var certificate = X509Certificate2.CreateFromPem(File.ReadAllText(Path.Combine(directory, "server.crt")));
var san = new AsnReader(certificate.Extensions["2.5.29.17"]!.RawData, AsnEncodingRules.DER).ReadSequence();
Assert.Equal("urn:voicecat:identity:ed25519:" + Convert.ToHexString(credentials.Identity.PublicKey).ToLowerInvariant(),
san.ReadCharacterString(UniversalTagNumber.IA5String, new Asn1Tag(TagClass.ContextSpecific, 6)));
Assert.False(san.HasData);
}
using var restored = ServerCredentials.LoadOrCreate(directory, "ignored after creation");
Assert.Equal(identityFingerprint, restored.Identity.Fingerprint);
Assert.Equal(certificateFingerprint, restored.CertificateFingerprint);
File.Delete(Path.Combine(directory, "server.key"));
Assert.Throws<InvalidDataException>(() => ServerCredentials.LoadOrCreate(directory, "unchanged"));
using var stillPresent = ServerIdentity.Load(Path.Combine(directory, "identity.key"));
Assert.Equal(identityFingerprint, stillPresent.Fingerprint);
}
finally { if (Directory.Exists(directory)) Directory.Delete(directory, true); }
}
[Fact]
public void TofuRequiresExplicitPinAndPreservesCppFileFormat()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-pins-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "pins.txt");
string fingerprint = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
try
{
var store = new TofuStore(path);
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
Assert.False(File.Exists(path));
store.Pin("localhost", 9987, fingerprint);
Assert.Equal($"localhost:9987 {fingerprint.ToLowerInvariant()}\n", File.ReadAllText(path));
store = new(path);
Assert.Equal(TofuStatus.Matched, store.Check("localhost", 9987, fingerprint));
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, new string('0', 64)));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, fingerprint));
store.Remove("localhost", 9987);
Assert.Equal(TofuStatus.FirstConnect, new TofuStore(path).Check("localhost", 9987, fingerprint));
File.WriteAllText(path, "localhost:9987 " + new string('g', 64));
Assert.Throws<InvalidDataException>(() => new TofuStore(path));
}
finally { Directory.Delete(directory, true); }
}
}
@@ -0,0 +1,97 @@
using System.Diagnostics;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class TlsInteropTests
{
[TlsOracleFact]
public async Task ManagedClientAndCppServerAgreeOnExporterKeysAndCertificate()
{
string? oracle = Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE");
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tls-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
var start = new ProcessStartInfo(oracle!) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true };
start.ArgumentList.Add(directory);
using var process = Process.Start(start)!;
var error = process.StandardError.ReadToEndAsync();
var stdout = process.StandardOutput.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
int port = 0;
while (!int.TryParse(File.Exists(Path.Combine(directory, "port.txt")) ? await File.ReadAllTextAsync(Path.Combine(directory, "port.txt"), timeout.Token) : "", out port))
{
Assert.False(process.HasExited, "C++ TLS oracle exited before listening.");
await Task.Delay(20, timeout.Token);
}
using var certificate = X509Certificate2.CreateFromPem(await File.ReadAllTextAsync(Path.Combine(directory, "server.crt"), timeout.Token));
string fingerprint = Convert.ToHexString(SHA256.HashData(certificate.RawData));
using var credentials = ServerCredentials.LoadOrCreate(directory, "existing C++ identity");
Assert.Equal(fingerprint, credentials.CertificateFingerprint);
Assert.Equal(32, credentials.Identity.PublicKey.Length);
using var client = TlsSession.CreateClient(value => value == fingerprint);
using var socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
await socket.ConnectAsync("127.0.0.1", port, timeout.Token);
byte[] buffer = new byte[16384];
async Task Flush()
{
while (client.PendingCiphertextBytes > 0)
{
int count = client.DrainCiphertext(buffer);
int sent = 0;
while (sent < count) sent += await socket.SendAsync(buffer.AsMemory(sent, count - sent), SocketFlags.None, timeout.Token);
}
}
async Task Receive()
{
int count = await socket.ReceiveAsync(buffer, SocketFlags.None, timeout.Token);
Assert.True(count > 0, "TLS oracle closed unexpectedly.");
client.ReceiveCiphertext(buffer.AsSpan(0, count));
}
while (!client.IsReady) { await Flush(); await Receive(); }
await Flush();
byte[] packet = new byte[41];
int received = 0;
while (received < packet.Length)
{
int count = client.ReadPlaintext(packet.AsSpan(received));
received += count;
if (count == 0) { await Flush(); await Receive(); }
}
using var decryptor = client.CreateMediaDecryptor();
byte[] plaintext = new byte[5];
Assert.True(decryptor.TryDecrypt(packet, plaintext, out var header, out _));
Assert.Equal("hello"u8.ToArray(), plaintext);
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
using var encryptor = client.CreateMediaEncryptor();
encryptor.Encrypt(header, plaintext, packet);
client.WritePlaintext(packet);
await Flush();
byte[] ack = new byte[1];
while (client.ReadPlaintext(ack) == 0) { await Flush(); await Receive(); }
Assert.Equal(1, ack[0]);
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await error);
await stdout;
}
finally
{
if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); }
Directory.Delete(directory, recursive: true);
}
}
}
public sealed class TlsOracleFactAttribute : FactAttribute
{
public TlsOracleFactAttribute()
{
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("VOICECAT_TLS_ORACLE")))
Skip = "Build the native TLS oracle and set VOICECAT_TLS_ORACLE to its executable path.";
}
}
+111
View File
@@ -0,0 +1,111 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
namespace VoiceCat.Tests;
public class TlsTests
{
[Fact]
public void ManagedTlsHandshakeExportsMatchingDirectionalKeys()
{
var (pem, key, fingerprint) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(value => value == fingerprint);
Assert.Throws<InvalidOperationException>(() => client.CreateMediaEncryptor());
Handshake(client, server);
Assert.Equal(fingerprint, client.PeerCertificateFingerprint);
Assert.Equal(server.ExportMediaKey(0), client.ExportMediaKey(0));
Assert.Equal(server.ExportMediaKey(1), client.ExportMediaKey(1));
Assert.NotEqual(client.ExportMediaKey(0), client.ExportMediaKey(1));
client.WritePlaintext("hello"u8);
Pump(client, server);
byte[] output = new byte[5];
Assert.Equal(5, server.ReadPlaintext(output));
Assert.Equal("hello"u8.ToArray(), output);
using var encryptor = server.CreateMediaEncryptor();
using var decryptor = client.CreateMediaDecryptor();
byte[] packet = new byte[41];
encryptor.Encrypt(new(MediaFrameType.Voice, 0, 0, 42, 0, 960), "hello"u8, packet);
Assert.True(decryptor.TryDecrypt(packet, output, out _, out _));
Assert.Equal("hello"u8.ToArray(), output);
}
[Fact]
public void CertificateRejectionPreventsApplicationDataAndMediaKeys()
{
var (pem, key, _) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(_ => false);
Assert.ThrowsAny<IOException>(() => Handshake(client, server));
Assert.False(client.IsReady);
Assert.Throws<InvalidOperationException>(() => client.CreateMediaDecryptor());
Assert.Throws<InvalidOperationException>(() => client.WritePlaintext("secret"u8));
}
[Fact]
public void CloseNotifyEndsSessionAndAbruptEofIsRejected()
{
var (pem, key, fingerprint) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
using var client = TlsSession.CreateClient(value => value == fingerprint);
Handshake(client, server);
client.Close();
Pump(client, server);
Assert.False(client.IsReady);
Assert.False(server.IsReady);
server.CompleteInput();
using var incomplete = TlsSession.CreateClient(_ => true);
Assert.ThrowsAny<IOException>(() => incomplete.CompleteInput());
}
[Fact]
public void TlsTwelveCannotNegotiateWithManagedServer()
{
var (pem, key, _) = Credentials();
using var server = TlsSession.CreateServer(pem, key);
var legacy = new Org.BouncyCastle.Tls.TlsClientProtocol();
legacy.Connect(new LegacyPeer());
byte[] hello = new byte[legacy.GetAvailableOutputBytes()];
legacy.ReadOutput(hello, 0, hello.Length);
Assert.ThrowsAny<IOException>(() => server.ReceiveCiphertext(hello));
Assert.False(server.IsReady);
Assert.Throws<InvalidOperationException>(() => server.CreateMediaEncryptor());
}
private sealed class LegacyPeer() : Org.BouncyCastle.Tls.DefaultTlsClient(new Org.BouncyCastle.Tls.Crypto.Impl.BC.BcTlsCrypto())
{
protected override Org.BouncyCastle.Tls.ProtocolVersion[] GetSupportedVersions() => [Org.BouncyCastle.Tls.ProtocolVersion.TLSv12];
public override Org.BouncyCastle.Tls.TlsAuthentication GetAuthentication() => throw new InvalidOperationException("TLS 1.2 must be rejected before authentication.");
}
internal static (string Certificate, string Key, string Fingerprint) Credentials()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new System.Security.Cryptography.X509Certificates.CertificateRequest("CN=VoiceCat TLS test", key, HashAlgorithmName.SHA256);
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(1));
return (certificate.ExportCertificatePem(), key.ExportPkcs8PrivateKeyPem(), Convert.ToHexString(SHA256.HashData(certificate.RawData)));
}
internal static void Handshake(TlsSession client, TlsSession server)
{
for (int i = 0; i < 100 && (!client.IsReady || !server.IsReady); i++)
{
Pump(client, server);
Pump(server, client);
}
Assert.True(client.IsReady);
Assert.True(server.IsReady);
}
private static void Pump(TlsSession sender, TlsSession receiver)
{
byte[] buffer = new byte[17];
while (sender.PendingCiphertextBytes > 0)
{
int count = sender.DrainCiphertext(buffer);
receiver.ReceiveCiphertext(buffer.AsSpan(0, count));
}
}
}
@@ -0,0 +1,49 @@
using VoiceCat.Crypto;
namespace VoiceCat.Tests;
public class TofuTlsTests
{
[Fact]
public void RealHandshakesRequireAcceptanceAndRejectChangedCertificatesAfterRestart()
{
string directory = Path.Combine(Path.GetTempPath(), "voicecat-tofu-tls-" + Guid.NewGuid());
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, "pins.txt");
try
{
using var credentials = ServerCredentials.LoadOrCreate(Path.Combine(directory, "server"), "server");
var store = new TofuStore(path);
using (var server = credentials.CreateTlsSession())
using (var rejected = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
return false;
}))
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(rejected, server));
Assert.False(File.Exists(path));
using (var server = credentials.CreateTlsSession())
using (var accepted = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.FirstConnect, store.Check("localhost", 9987, fingerprint));
store.Pin("localhost", 9987, fingerprint);
return true;
}))
TlsTests.Handshake(accepted, server);
store = new(path);
using (var server = credentials.CreateTlsSession())
using (var returning = TlsSession.CreateClient(fingerprint => store.Check("localhost", 9987, fingerprint) == TofuStatus.Matched))
TlsTests.Handshake(returning, server);
using var rotated = ServerCredentials.LoadOrCreate(Path.Combine(directory, "rotated"), "server");
using (var server = rotated.CreateTlsSession())
using (var mismatch = TlsSession.CreateClient(fingerprint =>
{
Assert.Equal(TofuStatus.Mismatch, store.Check("localhost", 9987, fingerprint));
return false;
}))
Assert.ThrowsAny<IOException>(() => TlsTests.Handshake(mismatch, server));
Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint));
}
finally { Directory.Delete(directory, true); }
}
}