using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; namespace PostgresService.WebApi; public sealed class SecretStore { private const int Iterations = 210_000; private readonly string _dataRoot; private readonly object _sync = new(); public SecretStore(IConfiguration configuration) { _dataRoot = configuration["POSTGRES_SERVICE_DATA_ROOT"] ?? Environment.GetEnvironmentVariable("POSTGRES_SERVICE_DATA_ROOT") ?? Path.Combine(AppContext.BaseDirectory, "data"); Directory.CreateDirectory(_dataRoot); } public bool VerifyAdminPassword(string value) => Verify("admin-password", value); public bool VerifyEnrollmentToken(string value) => Verify("enrollment-token", value); public string RotateEnrollmentToken() { var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); WriteHash("enrollment-token", token); return token; } public void EnsureInitialized() { lock (_sync) { PromoteSeed("admin-password"); PromoteSeed("enrollment-token"); } } private void PromoteSeed(string name) { var hashPath = Path.Combine(_dataRoot, $"{name}.hash"); if (File.Exists(hashPath)) { return; } var seedPath = Path.Combine(_dataRoot, $"{name}.seed"); if (!File.Exists(seedPath)) { throw new InvalidOperationException($"缺少 {name} 初始化文件。"); } var seed = File.ReadAllText(seedPath).TrimEnd('\r', '\n'); if (string.IsNullOrWhiteSpace(seed)) { throw new InvalidOperationException($"{name} 不能为空。"); } WriteHash(name, seed); File.Delete(seedPath); } private bool Verify(string name, string value) { if (string.IsNullOrEmpty(value)) { return false; } var path = Path.Combine(_dataRoot, $"{name}.hash"); if (!File.Exists(path)) { return false; } var parts = File.ReadAllText(path).Trim().Split('$'); if (parts.Length != 4 || parts[0] != "pbkdf2-sha256" || !int.TryParse(parts[1], out var iterations)) { return false; } try { var salt = Convert.FromBase64String(parts[2]); var expected = Convert.FromBase64String(parts[3]); var actual = Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(value), salt, iterations, HashAlgorithmName.SHA256, expected.Length); return CryptographicOperations.FixedTimeEquals(actual, expected); } catch (FormatException) { return false; } } private void WriteHash(string name, string value) { var salt = RandomNumberGenerator.GetBytes(16); var hash = Rfc2898DeriveBytes.Pbkdf2( Encoding.UTF8.GetBytes(value), salt, Iterations, HashAlgorithmName.SHA256, 32); var content = $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}\n"; var destination = Path.Combine(_dataRoot, $"{name}.hash"); var temporary = destination + ".tmp"; File.WriteAllText(temporary, content, new UTF8Encoding(false)); File.Move(temporary, destination, true); if (!OperatingSystem.IsWindows()) { File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite); } } } public sealed class AdminSessionStore { private static readonly TimeSpan Lifetime = TimeSpan.FromHours(12); private readonly ConcurrentDictionary _sessions = new(); public string Create() { RemoveExpired(); var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); _sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime); return token; } public bool Validate(string? token) { if (string.IsNullOrWhiteSpace(token) || !_sessions.TryGetValue(token, out var expiresAt)) { return false; } if (expiresAt <= DateTimeOffset.UtcNow) { _sessions.TryRemove(token, out _); return false; } _sessions[token] = DateTimeOffset.UtcNow.Add(Lifetime); return true; } public void Revoke(string? token) { if (!string.IsNullOrWhiteSpace(token)) { _sessions.TryRemove(token, out _); } } private void RemoveExpired() { var now = DateTimeOffset.UtcNow; foreach (var entry in _sessions.Where(item => item.Value <= now)) { _sessions.TryRemove(entry.Key, out _); } } }