| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 4 | | |
| | | 5 | | namespace ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | |
| | | 7 | | /// <summary> |
| | | 8 | | /// How many registration-attempt notices (UC-47 AF-47b) one address has been sent in its current |
| | | 9 | | /// window (FR-SR-14): at most <see cref="MaxNotices" /> a day, whichever scopes the attempts named. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// <para> |
| | | 13 | | /// <b>Why it exists.</b> Registering with an address that is already taken mails its holder a |
| | | 14 | | /// notice. The response is the same either way, so the caller cannot tell the notice went out |
| | | 15 | | /// — but the holder can, once per request. The per-IP rate limit bounds one caller, not one |
| | | 16 | | /// recipient: an attacker with many source addresses could otherwise fill an inbox with them. |
| | | 17 | | /// </para> |
| | | 18 | | /// <para> |
| | | 19 | | /// <b>What it holds.</b> The address as a SHA-256 digest of its trimmed, lower-cased form — |
| | | 20 | | /// never the address itself — the notices sent in the window, and when the window ends. The |
| | | 21 | | /// first notice after a window has ended opens a new one. A row is purged with the |
| | | 22 | | /// single-use tokens once its window has ended and the grace period passed (NFR-19), so no |
| | | 23 | | /// row outlives its use by more than that. |
| | | 24 | | /// </para> |
| | | 25 | | /// <para> |
| | | 26 | | /// The digest is pseudonymisation, not anonymisation: whoever already suspects an address can |
| | | 27 | | /// hash it and look for it. It is personal data for that reason, and recorded as such in the |
| | | 28 | | /// Data Protection Document. |
| | | 29 | | /// </para> |
| | | 30 | | /// </remarks> |
| | | 31 | | public class AttemptNoticeBudget : Entity<long> |
| | | 32 | | { |
| | | 33 | | /// <summary>The notices one address may be sent in a window.</summary> |
| | | 34 | | public const int MaxNotices = 3; |
| | | 35 | | |
| | | 36 | | /// <summary>How long a window lasts, from the first notice sent in it.</summary> |
| | 2 | 37 | | public static readonly TimeSpan Window = TimeSpan.FromDays(1); |
| | | 38 | | |
| | | 39 | | /// <summary> |
| | | 40 | | /// SHA-256 of the trimmed, lower-cased address, as lowercase hex (<see cref="AddressHashOf" />). |
| | | 41 | | /// Unique: one row per address. |
| | | 42 | | /// </summary> |
| | 15 | 43 | | public string AddressHash { get; set; } = string.Empty; |
| | | 44 | | |
| | | 45 | | /// <summary>Notices sent to the address since its current window opened.</summary> |
| | 9 | 46 | | public int NoticesSent { get; set; } |
| | | 47 | | |
| | | 48 | | /// <summary>When the current window ends; the next notice after it opens a new one.</summary> |
| | 12 | 49 | | public DateTime WindowEndsAt { get; set; } |
| | | 50 | | |
| | | 51 | | /// <summary> |
| | | 52 | | /// The digest an address is counted under. Case and surrounding spaces do not make another |
| | | 53 | | /// address, as they do not for the scope uniqueness rule (FR-PE-09). |
| | | 54 | | /// </summary> |
| | | 55 | | public static string AddressHashOf(string address) => |
| | 30 | 56 | | Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(address.Trim().ToLowerInvariant()))); |
| | | 57 | | } |