using System.Collections.Concurrent; using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; namespace JJServiceManagerRelay; /// 비밀번호 해시, 토큰, 코드 만들기 public static class Secrets { private const int Iterations = 210_000; public static string HashPassword(string password) { var salt = RandomNumberGenerator.GetBytes(16); var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA256, 32); return $"pbkdf2-sha256${Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}"; } public static bool VerifyPassword(string password, string stored) { try { var p = stored.Split('$'); if (p.Length != 4 || p[0] != "pbkdf2-sha256") return false; int iter = int.Parse(p[1]); var salt = Convert.FromBase64String(p[2]); var expect = Convert.FromBase64String(p[3]); var got = Rfc2898DeriveBytes.Pbkdf2(password ?? "", salt, iter, HashAlgorithmName.SHA256, expect.Length); return CryptographicOperations.FixedTimeEquals(got, expect); } catch { return false; } } /// 비교용 비밀번호를 늘 계산해서 "없는 아이디"도 같은 시간이 걸리게 public static readonly string DummyHash = HashPassword(Guid.NewGuid().ToString()); public static string NewToken() => Base64Url(RandomNumberGenerator.GetBytes(32)); public static byte[] Sha256(string s) => SHA256.HashData(Encoding.UTF8.GetBytes(s ?? "")); /// 등록 코드: XXXX-XXXX (헷갈리는 글자 O/0/I/1 제외) public static string NewCode() { const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; var sb = new StringBuilder(); for (int i = 0; i < 8; i++) { if (i == 4) sb.Append('-'); sb.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]); } return sb.ToString(); } /// 입력한 코드 정리 (대소문자·공백·하이픈 무시) public static string NormalizeCode(string code) { var s = new string((code ?? "").ToUpperInvariant().Where(char.IsLetterOrDigit).ToArray()); return s.Length == 8 ? s[..4] + "-" + s[4..] : s; } public static string Base64Url(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_'); public static byte[] FromBase64Url(string s) { s = s.Replace('-', '+').Replace('_', '/'); switch (s.Length % 4) { case 2: s += "=="; break; case 3: s += "="; break; } return Convert.FromBase64String(s); } } /// 권한 티켓 서명 (ECDSA P-256). 대상 PC가 서버 공개키로 확인한다. public sealed class TicketSigner { private readonly ECDsa _key; public string PublicKeyBase64 { get; } public TicketSigner(string pkcs8Base64) { _key = ECDsa.Create(); _key.ImportPkcs8PrivateKey(Convert.FromBase64String(pkcs8Base64), out _); PublicKeyBase64 = Convert.ToBase64String(_key.ExportSubjectPublicKeyInfo()); } public static string NewPrivateKey() { using var k = ECDsa.Create(ECCurve.NamedCurves.nistP256); return Convert.ToBase64String(k.ExportPkcs8PrivateKey()); } /// 티켓 = base64url(JSON) + "." + base64url(서명) public string Sign(JsonObject payload) { var body = Encoding.UTF8.GetBytes(payload.ToJsonString()); var sig = _key.SignData(body, HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); return Secrets.Base64Url(body) + "." + Secrets.Base64Url(sig); } } /// 로그인·등록 실패 제한: 5분 동안 10번 넘게 틀리면 15분 거부 public sealed class Throttle { private readonly ConcurrentDictionary> _fails = new(); private readonly ConcurrentDictionary _blocked = new(); private static readonly TimeSpan Window = TimeSpan.FromMinutes(5); private static readonly TimeSpan Block = TimeSpan.FromMinutes(15); private const int MaxFails = 10; public bool IsBlocked(params string[] keys) { var now = DateTime.UtcNow; foreach (var k in keys) { if (k == null) continue; if (_blocked.TryGetValue(k, out var until)) { if (until > now) return true; _blocked.TryRemove(k, out _); } } return false; } public void Fail(params string[] keys) { var now = DateTime.UtcNow; foreach (var k in keys) { if (k == null) continue; var list = _fails.GetOrAdd(k, _ => new List()); lock (list) { list.Add(now); list.RemoveAll(t => now - t > Window); if (list.Count > MaxFails) { _blocked[k] = now + Block; list.Clear(); } } } } public void Success(params string[] keys) { foreach (var k in keys) if (k != null) _fails.TryRemove(k, out _); } }