161 lines
4.8 KiB
C#
161 lines
4.8 KiB
C#
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<string, DateTimeOffset> _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 _);
|
|
}
|
|
}
|
|
}
|