using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Memory; namespace Nuuru.Server.Services { public interface IPowService { PowChallengeDto IssueChallenge(); bool ValidateSolution(string challengeId, string challenge, string nonce); string IssueCookieToken(string clientIp); bool ValidateCookieToken(string token, string clientIp); bool IsSsrRequest(HttpContext context); bool IsEnabled { get; } } public class PowChallengeDto { public required string Id { get; set; } public required string Challenge { get; set; } public int Difficulty { get; set; } } public sealed class PowService : IPowService { private readonly byte[] _signingKey; private readonly byte[] _ssrSecret; private readonly int _difficulty; private readonly int _cookieTtlMinutes; private readonly int _challengeTtlMinutes; public bool IsEnabled { get; } public PowService(IConfiguration configuration) { var section = configuration.GetSection("ProofOfWork"); IsEnabled = section.GetValue("Enabled", true); _difficulty = section.GetValue("Difficulty", 20); _cookieTtlMinutes = section.GetValue("CookieTtlMinutes", 1440); _challengeTtlMinutes = section.GetValue("ChallengeTtlMinutes", 5); var signingKey = section["SigningKey"] ?? "default-pow-signing-key-change-me"; _signingKey = Encoding.UTF8.GetBytes(signingKey); var ssrSecret = section["SsrSecret"] ?? "default-pow-ssr-secret-change-me"; _ssrSecret = Encoding.UTF8.GetBytes(ssrSecret); } public PowChallengeDto IssueChallenge() { var id = Guid.NewGuid().ToString("N"); var challengeBytes = RandomNumberGenerator.GetBytes(32); var challenge = Convert.ToHexString(challengeBytes).ToLowerInvariant(); // HMAC-sign the challenge so we can verify it without storing state var payload = $"{id}:{challenge}:{_difficulty}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; var hmac = HMACSHA256.HashData(_signingKey, Encoding.UTF8.GetBytes(payload)); var sig = Convert.ToHexString(hmac).ToLowerInvariant(); return new PowChallengeDto { // Embed everything in the ID so the server is stateless Id = $"{id}:{_difficulty}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}:{sig}", Challenge = challenge, Difficulty = _difficulty, }; } public bool ValidateSolution(string challengeId, string challenge, string nonce) { // Parse the signed challenge ID: guid:difficulty:timestamp:sig var parts = challengeId.Split(':'); if (parts.Length != 4) return false; var guid = parts[0]; if (!int.TryParse(parts[1], out var difficulty)) return false; if (!long.TryParse(parts[2], out var timestamp)) return false; var providedSigHex = parts[3]; // Check expiry var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (now - timestamp > _challengeTtlMinutes * 60) return false; // Verify HMAC — proves we issued this challenge var payload = $"{guid}:{challenge}:{difficulty}:{timestamp}"; var expectedHmac = HMACSHA256.HashData(_signingKey, Encoding.UTF8.GetBytes(payload)); var expectedSigHex = Convert.ToHexString(expectedHmac).ToLowerInvariant(); if (providedSigHex.Length != expectedSigHex.Length) return false; if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(providedSigHex), Encoding.UTF8.GetBytes(expectedSigHex))) return false; // Verify SHA-256 solution: SHA256(challenge + nonce) must have `difficulty` leading zero bits var input = Encoding.UTF8.GetBytes(challenge + nonce); var hash = SHA256.HashData(input); return HasLeadingZeroBits(hash, difficulty); } private static bool HasLeadingZeroBits(byte[] hash, int requiredBits) { var fullBytes = requiredBits / 8; var remainingBits = requiredBits % 8; for (var i = 0; i < fullBytes; i++) { if (hash[i] != 0) return false; } if (remainingBits > 0 && fullBytes < hash.Length) { var mask = (byte)(0xFF << (8 - remainingBits)); if ((hash[fullBytes] & mask) != 0) return false; } return true; } public string IssueCookieToken(string clientIp) { var guid = Guid.NewGuid().ToString("N"); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var payload = $"{guid}|{timestamp}|{clientIp}"; var hmac = HMACSHA256.HashData(_signingKey, Encoding.UTF8.GetBytes(payload)); var sig = WebEncoders.Base64UrlEncode(hmac); return $"{payload}|{sig}"; } public bool ValidateCookieToken(string token, string clientIp) { if (string.IsNullOrWhiteSpace(token)) return false; // Format: guid|timestamp|ip|sig — split from the right to handle IPs with special chars var lastPipe = token.LastIndexOf('|'); if (lastPipe < 0) return false; var payload = token[..lastPipe]; var sigPart = token[(lastPipe + 1)..]; // Parse payload: guid|timestamp|ip var firstPipe = payload.IndexOf('|'); if (firstPipe < 0) return false; var secondPipe = payload.IndexOf('|', firstPipe + 1); if (secondPipe < 0) return false; if (!long.TryParse(payload[(firstPipe + 1)..secondPipe], out var timestamp)) return false; var tokenIp = payload[(secondPipe + 1)..]; // Verify IP matches if (tokenIp != clientIp) return false; // Check expiry var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (now - timestamp > _cookieTtlMinutes * 60) return false; byte[] providedSig; try { providedSig = WebEncoders.Base64UrlDecode(sigPart); } catch { return false; } var expectedSig = HMACSHA256.HashData(_signingKey, Encoding.UTF8.GetBytes(payload)); if (providedSig.Length != expectedSig.Length) return false; return CryptographicOperations.FixedTimeEquals(providedSig, expectedSig); } public bool IsSsrRequest(HttpContext context) { if (!context.Request.Headers.TryGetValue("X-SSR-Secret", out var headerValue)) return false; var headerBytes = Encoding.UTF8.GetBytes(headerValue.ToString()); if (headerBytes.Length != _ssrSecret.Length) return false; return CryptographicOperations.FixedTimeEquals(headerBytes, _ssrSecret); } } }