JJServiceManagerRelay/server/Security.cs

153 lines
5.3 KiB
C#

using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace JJServiceManagerRelay;
/// <summary>비밀번호 해시, 토큰, 코드 만들기</summary>
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;
}
}
/// <summary>비교용 비밀번호를 늘 계산해서 "없는 아이디"도 같은 시간이 걸리게</summary>
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 ?? ""));
/// <summary>등록 코드: XXXX-XXXX (헷갈리는 글자 O/0/I/1 제외)</summary>
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();
}
/// <summary>입력한 코드 정리 (대소문자·공백·하이픈 무시)</summary>
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);
}
}
/// <summary>권한 티켓 서명 (ECDSA P-256). 대상 PC가 서버 공개키로 확인한다.</summary>
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());
}
/// <summary>티켓 = base64url(JSON) + "." + base64url(서명)</summary>
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);
}
}
/// <summary>로그인·등록 실패 제한: 5분 동안 10번 넘게 틀리면 15분 거부</summary>
public sealed class Throttle
{
private readonly ConcurrentDictionary<string, List<DateTime>> _fails = new();
private readonly ConcurrentDictionary<string, DateTime> _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<DateTime>());
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 _);
}
}