| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using System.Text; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Hashes the single-use tokens delivered by email — the password reset token of UC-12 and the |
| | | 8 | | /// email verification token of UC-14 — so that neither is stored in a form that can be presented |
| | | 9 | | /// back to the API. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// <para> |
| | | 13 | | /// This exists because both were stored in plaintext (Threat Model TH-14) while the |
| | | 14 | | /// second-factor recovery codes beside them were hashed, even though a reset token is the |
| | | 15 | | /// stronger primitive of the two: a recovery code still requires the password, and a reset |
| | | 16 | | /// token replaces it. Anyone who could read the table could complete a reset for any account |
| | | 17 | | /// holding a live token, which did not break the Argon2id work protecting the password so |
| | | 18 | | /// much as walk around it. |
| | | 19 | | /// </para> |
| | | 20 | | /// <para> |
| | | 21 | | /// Unsalted SHA-256, which is the same choice already made for recovery codes and is |
| | | 22 | | /// deliberate rather than an oversight. A salt defends a low-entropy secret against a |
| | | 23 | | /// precomputed table; these tokens are 48 characters drawn from a CSPRNG, so there is no |
| | | 24 | | /// table to precompute and nothing for a salt to add. What it costs to leave out is what |
| | | 25 | | /// makes this workable at all: an unsalted digest is deterministic, so the presented token |
| | | 26 | | /// can be hashed once and looked up against a unique index. A per-row salt would force a |
| | | 27 | | /// scan of every live token on every attempt, because the caller presents the token alone |
| | | 28 | | /// and there is nothing else to narrow the candidates by. |
| | | 29 | | /// </para> |
| | | 30 | | /// <para> |
| | | 31 | | /// Both directions go through here so the value written at issue and the value compared at |
| | | 32 | | /// presentation cannot drift apart. |
| | | 33 | | /// </para> |
| | | 34 | | /// <para> |
| | | 35 | | /// Lowercase hex rather than the raw bytes, and that choice is about equality rather than |
| | | 36 | | /// about storage. <c>byte[] == byte[]</c> is value equality once EF has translated it into |
| | | 37 | | /// SQL and reference equality everywhere else, so the handler's lookup matched correctly |
| | | 38 | | /// against PostgreSQL and silently matched nothing against an in-memory repository — which |
| | | 39 | | /// is exactly how the unit suite reads it, and how this was caught. A string compares by |
| | | 40 | | /// value in both, so the expression means one thing wherever it runs. The digest is 32 bytes |
| | | 41 | | /// either way; hex spends 64 characters to remove that ambiguity. |
| | | 42 | | /// </para> |
| | | 43 | | /// </remarks> |
| | | 44 | | public static class SingleUseTokenHash |
| | | 45 | | { |
| | | 46 | | /// <summary>The length of the value <see cref="Of" /> returns: SHA-256 as lowercase hex.</summary> |
| | | 47 | | public const int Length = 64; |
| | | 48 | | |
| | | 49 | | /// <summary>The stored form of <paramref name="token" />.</summary> |
| | | 50 | | public static string Of(string token) => |
| | 623 | 51 | | Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))).ToLowerInvariant(); |
| | | 52 | | } |