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(); } }