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