Files
voice-cat/src/VoiceCat.Crypto/TofuStore.cs
T
Talon 08e6c5930a
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s
Retire legacy implementations and flatten managed layout
2026-09-21 00:11:32 +02:00

73 lines
2.7 KiB
C#

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