|
| 1 | +using System.Security.Cryptography; |
| 2 | +using System.Text; |
| 3 | + |
| 4 | +namespace PassKey.Core.Services; |
| 5 | + |
| 6 | +/// <summary> |
| 7 | +/// k-anonymity client for the Have I Been Pwned "Pwned Passwords" API. |
| 8 | +/// </summary> |
| 9 | +/// <remarks> |
| 10 | +/// <para>Wire protocol:</para> |
| 11 | +/// <list type="number"> |
| 12 | +/// <item>Compute <c>SHA1(password)</c> → 40 hex chars (uppercase, no separators).</item> |
| 13 | +/// <item>Send the first <b>5</b> hex chars to <c>https://api.pwnedpasswords.com/range/{prefix}</c>.</item> |
| 14 | +/// <item>Server returns plain text — one line per hash with that prefix in the form |
| 15 | +/// <c>{suffix35}:{count}</c>.</item> |
| 16 | +/// <item>Search the response locally for the 35-char suffix of our hash. The matching |
| 17 | +/// line's count is the number of breaches; absence means 0.</item> |
| 18 | +/// </list> |
| 19 | +/// <para>The full password (and its full SHA-1) never leaves the device.</para> |
| 20 | +/// </remarks> |
| 21 | +public sealed class HibpService : IHibpService |
| 22 | +{ |
| 23 | + private const string BaseAddress = "https://api.pwnedpasswords.com/range/"; |
| 24 | + private const int PrefixLength = 5; |
| 25 | + |
| 26 | + // Static singleton HttpClient — same pattern as UpdateService. HttpClient is |
| 27 | + // designed to be reused across calls (DNS / TCP pooling, thread-safe). Disposing |
| 28 | + // per-call would exhaust ephemeral ports under load. |
| 29 | + private static readonly HttpClient DefaultClient = new() |
| 30 | + { |
| 31 | + Timeout = TimeSpan.FromSeconds(5), |
| 32 | + }; |
| 33 | + |
| 34 | + private readonly HttpClient _http; |
| 35 | + |
| 36 | + /// <summary>Production constructor — uses the shared singleton client.</summary> |
| 37 | + public HibpService() : this(DefaultClient) |
| 38 | + { |
| 39 | + } |
| 40 | + |
| 41 | + /// <summary>Test seam — injects a <see cref="HttpClient"/> with a mock handler.</summary> |
| 42 | + public HibpService(HttpClient http) |
| 43 | + { |
| 44 | + _http = http ?? throw new ArgumentNullException(nameof(http)); |
| 45 | + // Identify the client per HIBP courtesy guidance. This is the only header sent. |
| 46 | + if (!_http.DefaultRequestHeaders.UserAgent.Any()) |
| 47 | + { |
| 48 | + _http.DefaultRequestHeaders.UserAgent.ParseAdd("PassKey/2.0 (+https://pass-key.it)"); |
| 49 | + } |
| 50 | + } |
| 51 | + |
| 52 | + /// <inheritdoc/> |
| 53 | + public async Task<int> CheckPasswordAsync(string password, CancellationToken cancellationToken = default) |
| 54 | + { |
| 55 | + if (string.IsNullOrEmpty(password)) return 0; |
| 56 | + |
| 57 | + var hash = Sha1Hex(password); |
| 58 | + var prefix = hash[..PrefixLength]; |
| 59 | + var suffix = hash[PrefixLength..]; |
| 60 | + |
| 61 | + var url = BaseAddress + prefix; |
| 62 | + using var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false); |
| 63 | + response.EnsureSuccessStatusCode(); |
| 64 | + |
| 65 | + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); |
| 66 | + return ParseBreachCount(body, suffix); |
| 67 | + } |
| 68 | + |
| 69 | + /// <summary>Parses the line "<c>suffix:count</c>" matching the supplied suffix and returns the count.</summary> |
| 70 | + internal static int ParseBreachCount(string body, string suffix) |
| 71 | + { |
| 72 | + // Iterate lines without allocating arrays for each token. The HIBP response |
| 73 | + // averages ~500 lines (~30 KB) — small but worth avoiding the LINQ overhead. |
| 74 | + var span = body.AsSpan(); |
| 75 | + while (true) |
| 76 | + { |
| 77 | + var newline = span.IndexOf('\n'); |
| 78 | + ReadOnlySpan<char> line = newline < 0 ? span : span[..newline]; |
| 79 | + if (line.Length > 0 && line[^1] == '\r') line = line[..^1]; |
| 80 | + |
| 81 | + var colon = line.IndexOf(':'); |
| 82 | + if (colon == suffix.Length && line[..colon].Equals(suffix, StringComparison.OrdinalIgnoreCase)) |
| 83 | + { |
| 84 | + if (int.TryParse(line[(colon + 1)..], out var count)) |
| 85 | + return count; |
| 86 | + } |
| 87 | + |
| 88 | + if (newline < 0) break; |
| 89 | + span = span[(newline + 1)..]; |
| 90 | + } |
| 91 | + return 0; |
| 92 | + } |
| 93 | + |
| 94 | + /// <summary>Uppercase, no-separator SHA-1 hex digest of <paramref name="text"/> (UTF-8 bytes).</summary> |
| 95 | + internal static string Sha1Hex(string text) |
| 96 | + { |
| 97 | + Span<byte> hash = stackalloc byte[20]; // SHA-1 is 160 bits |
| 98 | + SHA1.HashData(Encoding.UTF8.GetBytes(text), hash); |
| 99 | + return Convert.ToHexString(hash); // uppercase by contract |
| 100 | + } |
| 101 | +} |
0 commit comments