diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7935604..dd41afe 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -53,4 +53,4 @@ jobs: ./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 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 diff --git a/PROGRESS.md b/PROGRESS.md index c91cf88..099a988 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -10,6 +10,19 @@ up instantly. Newest status at the top. ## ▶ 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`. Added `dotnet/` solution, schema code generation, pipe framing, immutable voice headers, directional media encryption/decryption, and xUnit conformance tests. Both platform diff --git a/docs/api-dotnet.md b/docs/api-dotnet.md index 937201a..24fde33 100644 --- a/docs/api-dotnet.md +++ b/docs/api-dotnet.md @@ -31,8 +31,8 @@ Higher layers decide which values they support. `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 from TLS exporter keys when TLS is implemented. Raw-key -constructors support conformance tests and the future TLS integration. +Production constructs them through `TlsSession` media factories after its handshake. +Raw-key constructors support conformance tests. `MediaEncryptor.Encrypt(VoiceFrameHeader, ReadOnlySpan, Span)` writes 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 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)` 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:`; 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. diff --git a/docs/porting-to-dotnet.md b/docs/porting-to-dotnet.md index 0598091..1ea9c2e 100644 --- a/docs/porting-to-dotnet.md +++ b/docs/porting-to-dotnet.md @@ -1,6 +1,8 @@ # 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. **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 @@ -676,6 +678,16 @@ not assumed. **Exit criterion:** C# client completes a TLS 1.3 handshake with the C++ server, derives 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) diff --git a/docs/security.md b/docs/security.md index b7e6b4a..6d6bd9c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 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:`. 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 are not required in v1. diff --git a/dotnet/README.md b/dotnet/README.md index 9edf9cc..61dfecb 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -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 0–31; 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. diff --git a/dotnet/oracle/CMakeLists.txt b/dotnet/oracle/CMakeLists.txt index efb56eb..cc9e7bc 100644 --- a/dotnet/oracle/CMakeLists.txt +++ b/dotnet/oracle/CMakeLists.txt @@ -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) diff --git a/dotnet/oracle/tls.cpp b/dotnet/oracle/tls.cpp new file mode 100644 index 0000000..b325b08 --- /dev/null +++ b/dotnet/oracle/tls.cpp @@ -0,0 +1,76 @@ +#ifdef _WIN32 +#include +#include +using socket_type = SOCKET; +static void close_socket(socket_type socket) { closesocket(socket); } +#else +#include +#include +#include +using socket_type = int; +static void close_socket(socket_type socket) { close(socket); } +#endif + +#include "crypto/crypto.h" +#include "net/voice_frame.h" +#include +#include +#include + +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(&address), sizeof(address)) != 0 || listen(listener, 1) != 0) return 1; + socklen_t length = sizeof(address); + if (getsockname(listener, reinterpret_cast(&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(-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(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 packet{}; + voicecat::net::serialize_header(header, packet.data()); + const std::array 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 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; + } +} diff --git a/dotnet/src/VoiceCat.Crypto/PrivateFiles.cs b/dotnet/src/VoiceCat.Crypto/PrivateFiles.cs new file mode 100644 index 0000000..7d315c3 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/PrivateFiles.cs @@ -0,0 +1,22 @@ +namespace VoiceCat.Crypto; + +internal static class PrivateFiles +{ + public static void Write(string path, ReadOnlySpan 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); } + } +} diff --git a/dotnet/src/VoiceCat.Crypto/ServerCredentials.cs b/dotnet/src/VoiceCat.Crypto/ServerCredentials.cs new file mode 100644 index 0000000..80dd0bf --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/ServerCredentials.cs @@ -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(); + } +} diff --git a/dotnet/src/VoiceCat.Crypto/ServerIdentity.cs b/dotnet/src/VoiceCat.Crypto/ServerIdentity.cs new file mode 100644 index 0000000..9603f10 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/ServerIdentity.cs @@ -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); + } +} diff --git a/dotnet/src/VoiceCat.Crypto/TlsSession.cs b/dotnet/src/VoiceCat.Crypto/TlsSession.cs new file mode 100644 index 0000000..7dc8d07 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/TlsSession.cs @@ -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 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 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 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 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 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 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 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 + ]; +} diff --git a/dotnet/src/VoiceCat.Crypto/TofuStore.cs b/dotnet/src/VoiceCat.Crypto/TofuStore.cs new file mode 100644 index 0000000..a851908 --- /dev/null +++ b/dotnet/src/VoiceCat.Crypto/TofuStore.cs @@ -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 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(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(pins, StringComparer.Ordinal); + updated.Remove(key); + Save(updated); + pins.Remove(key); + } + + private void Save(Dictionary 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(); + } +} diff --git a/dotnet/tests/VoiceCat.Tests/IdentityTests.cs b/dotnet/tests/VoiceCat.Tests/IdentityTests.cs new file mode 100644 index 0000000..0790129 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/IdentityTests.cs @@ -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(() => 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(() => new TofuStore(path)); + } + finally { Directory.Delete(directory, true); } + } +} diff --git a/dotnet/tests/VoiceCat.Tests/TlsInteropTests.cs b/dotnet/tests/VoiceCat.Tests/TlsInteropTests.cs new file mode 100644 index 0000000..4623a56 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/TlsInteropTests.cs @@ -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."; + } +} diff --git a/dotnet/tests/VoiceCat.Tests/TlsTests.cs b/dotnet/tests/VoiceCat.Tests/TlsTests.cs new file mode 100644 index 0000000..f655320 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/TlsTests.cs @@ -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(() => 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(() => Handshake(client, server)); + Assert.False(client.IsReady); + Assert.Throws(() => client.CreateMediaDecryptor()); + Assert.Throws(() => 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(() => 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(() => server.ReceiveCiphertext(hello)); + Assert.False(server.IsReady); + Assert.Throws(() => 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)); + } + } +} diff --git a/dotnet/tests/VoiceCat.Tests/TofuTlsTests.cs b/dotnet/tests/VoiceCat.Tests/TofuTlsTests.cs new file mode 100644 index 0000000..e155260 --- /dev/null +++ b/dotnet/tests/VoiceCat.Tests/TofuTlsTests.cs @@ -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(() => 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(() => TlsTests.Handshake(mismatch, server)); + Assert.Equal(TofuStatus.Matched, new TofuStore(path).Check("localhost", 9987, credentials.CertificateFingerprint)); + } + finally { Directory.Delete(directory, true); } + } +}