Add managed TLS interoperability and persisted credentials
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user