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
+22 -5
View File
@@ -1,7 +1,8 @@
# VoiceCat .NET rewrite
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.
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.
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
Prove BouncyCastle TLS 1.3 loopback and interoperability with the C++ mbedTLS server,
including exporter label `voicecat media v1`, one-byte direction contexts 0/1,
and TLS leaf certificate fingerprint pinning. Then implement the managed server,
tested first with the existing C++ CLI.
Port codec/DSP wrappers and their native packaging per Phase 3 of the porting plan.
The managed server follows, 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_include_directories(voicecat-dotnet-oracle PRIVATE ${CMAKE_SOURCE_DIR}/core/src)
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); }
}
}