< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.RecoveryCodes
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/RecoveryCodes.cs
Line coverage
100%
Covered lines: 52
Uncovered lines: 0
Coverable lines: 52
Total lines: 208
Line coverage: 100%
Branch coverage
100%
Covered branches: 34
Total branches: 34
Branch coverage: 100%
Method coverage
100%
Covered methods: 8
Fully covered methods: 8
Total methods: 8
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Issue()100%22100%
DigestOf(...)100%1414100%
Encode(...)100%66100%
NewCanonicalCode()100%11100%
Display(...)100%11100%
CurrentDigest(...)100%1010100%
LegacyDigest(...)100%22100%
Digest(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/RecoveryCodes.cs

#LineLine coverage
 1using System.Security.Cryptography;
 2using System.Text;
 3
 4namespace ArturRios.Heimdall.Command.Services;
 5
 6/// <summary>A recovery code as issued: the form shown to the person once, and the digest stored for it.</summary>
 7/// <param name="Code">The code as displayed, <c>XXXX-XXXX-XXXX-XXXX</c>.</param>
 8/// <param name="CodeHash">What <c>TwoFactorRecoveryCode.CodeHash</c> stores for it.</param>
 9public sealed record IssuedRecoveryCode(string Code, byte[] CodeHash);
 10
 11/// <summary>
 12///     The second-factor recovery codes of FR-2F-05 and FR-2F-12: how they are generated, how a
 13///     presented one is normalised, and how both are hashed. UC-37 and UC-40 issue them and
 14///     <see cref="TwoFactorFactorVerifier" /> matches them, and all three go through here so the
 15///     value written at issue and the value compared at presentation cannot drift apart.
 16/// </summary>
 17/// <remarks>
 18///     <para>
 19///         <b>Shape.</b> Ten random bytes from <see cref="RandomNumberGenerator" />, 80 bits, written
 20///         as sixteen characters of Crockford's base32 (<see cref="Alphabet" />: the digits and the
 21///         uppercase letters without I, L, O and U) and shown in four groups of four,
 22///         <c>XXXX-XXXX-XXXX-XXXX</c>. Crockford's rather than RFC 4648's because it leaves out the
 23///         letters people misread for digits and reads them back as those digits instead: a code
 24///         typed with an O, an I or an L still matches.
 25///     </para>
 26///     <para>
 27///         <b>What a presented code may look like.</b> Hyphens and whitespace are ignored wherever
 28///         they fall, and case is ignored. What remains is sixteen characters for a code issued
 29///         now, or eight for one issued before (two groups of four from <c>[A-Z0-9]</c>, stored as
 30///         the digest of the exact <c>XXXX-XXXX</c> uppercase form). Those keep matching until they
 31///         are used or the set is regenerated (FR-2F-20). Crockford's aliases apply to the sixteen
 32///         only: an older code may really contain an O, an I or an L, and mapping it would make it
 33///         miss. Any other length matches nothing.
 34///     </para>
 35///     <para>
 36///         <b>Why SHA-256 is enough.</b> A recovery code is not chosen by a person; it is 80 bits
 37///         drawn uniformly at random, so there is no likely subset for a guesser to start with.
 38///         Finding one particular code from its digest takes 2^79 SHA-256 evaluations on average,
 39///         millions of years at the 10^10 a second of a current GPU. The digest is unsalted, so one
 40///         pass over the space tests every stored code at once, which divides that work by the
 41///         number of codes stored: at a million codes it is still about 2^60 evaluations — years of
 42///         one GPU — to recover a single code of some account nobody chose, which is then good only
 43///         beside that account's password. A slow hash would add little at this entropy and would
 44///         cost a derivation per unused code on every presentation, since a code is matched against
 45///         its configuration's whole unused set. A keyed hash, with a pepper held outside the
 46///         database, is the step to take should the table ever grow by orders of magnitude. The
 47///         eight-character codes issued before carry fewer bits; regenerating replaces them (UC-40).
 48///     </para>
 49/// </remarks>
 50public static class RecoveryCodes
 51{
 52    /// <summary>How many codes a set holds (FR-2F-05).</summary>
 53    public const int Count = 10;
 54
 55    /// <summary>Crockford's base32 alphabet: each character carries five bits.</summary>
 56    public const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
 57
 58    /// <summary>The characters in a code, hyphens aside.</summary>
 59    public const int Length = 16;
 60
 61    /// <summary>The characters in a code issued before 80-bit codes, hyphen aside.</summary>
 62    public const int LegacyLength = 8;
 63
 64    private const int RandomBytes = 10;
 65    private const int GroupLength = 4;
 66    private const int BitsPerCharacter = 5;
 67
 68    /// <summary>A fresh set of <see cref="Count" /> codes, each with the digest to store for it.</summary>
 69    public static List<IssuedRecoveryCode> Issue()
 70    {
 5671        var codes = new List<IssuedRecoveryCode>(Count);
 72
 123273        for (var i = 0; i < Count; i++)
 74        {
 56075            var canonical = NewCanonicalCode();
 76
 56077            codes.Add(new IssuedRecoveryCode(Display(canonical), Digest(canonical)));
 78        }
 79
 5680        return codes;
 81    }
 82
 83    /// <summary>
 84    ///     The digest <paramref name="presented" /> would be stored under, or <c>null</c> when it
 85    ///     cannot be a recovery code of either shape and so matches nothing. See the remarks for
 86    ///     what is normalised.
 87    /// </summary>
 88    public static byte[]? DigestOf(string? presented)
 89    {
 16490        if (presented is null)
 91        {
 192            return null;
 93        }
 94
 16395        var stripped = new StringBuilder(presented.Length);
 96
 470197        foreach (var character in presented)
 98        {
 218999            if (character == '-' || char.IsWhiteSpace(character))
 100            {
 101                continue;
 102            }
 103
 104            // Neither alphabet has anything outside ASCII, and upper-casing one could turn it into
 105            // a letter that is in them (a dotless i into I, say).
 1857106            if (!char.IsAscii(character))
 107            {
 3108                return null;
 109            }
 110
 1854111            stripped.Append(char.ToUpperInvariant(character));
 112        }
 113
 160114        return stripped.Length switch
 160115        {
 78116            Length => CurrentDigest(stripped.ToString()),
 58117            LegacyLength => LegacyDigest(stripped.ToString()),
 24118            _ => null
 160119        };
 120    }
 121
 122    /// <summary>
 123    ///     <paramref name="bytes" /> in <see cref="Alphabet" />, five bits a character, most
 124    ///     significant first: the canonical form of a code. Ten bytes give the sixteen characters of
 125    ///     <see cref="Length" /> exactly. Public so the mapping can be pinned by known answers; issuing
 126    ///     goes through <see cref="Issue" />.
 127    /// </summary>
 128    /// <exception cref="ArgumentException">The length is not a multiple of five bytes.</exception>
 129    public static string Encode(ReadOnlySpan<byte> bytes)
 130    {
 565131        if (bytes.Length % 5 != 0)
 132        {
 1133            throw new ArgumentException("Base32 without padding needs a multiple of five bytes.", nameof(bytes));
 134        }
 135
 564136        var code = new StringBuilder(bytes.Length * 8 / BitsPerCharacter);
 564137        var buffer = 0;
 564138        var bits = 0;
 139
 12408140        foreach (var value in bytes)
 141        {
 5640142            buffer = (buffer << 8) | value;
 5640143            bits += 8;
 144
 14664145            while (bits >= BitsPerCharacter)
 146            {
 9024147                bits -= BitsPerCharacter;
 9024148                code.Append(Alphabet[(buffer >> bits) & 0b11111]);
 149            }
 150        }
 151
 564152        return code.ToString();
 153    }
 154
 155    /// <summary>Sixteen characters for <see cref="RandomBytes" /> random bytes.</summary>
 156    private static string NewCanonicalCode()
 157    {
 560158        Span<byte> random = stackalloc byte[RandomBytes];
 560159        RandomNumberGenerator.Fill(random);
 160
 560161        return Encode(random);
 162    }
 163
 164    /// <summary><c>XXXX-XXXX-XXXX-XXXX</c>.</summary>
 165    private static string Display(string canonical) =>
 560166        string.Join('-', Enumerable.Range(0, Length / GroupLength)
 2800167            .Select(group => canonical.Substring(group * GroupLength, GroupLength)));
 168
 169    /// <summary>
 170    ///     A sixteen-character code: Crockford's decode aliases applied (O reads as 0, I and L as 1),
 171    ///     then hashed in that canonical form. A U, or anything else outside the alphabet, matches
 172    ///     nothing.
 173    /// </summary>
 174    private static byte[]? CurrentDigest(string stripped)
 175    {
 78176        var canonical = new StringBuilder(Length);
 177
 2618178        foreach (var character in stripped)
 179        {
 1232180            var decoded = character switch
 1232181            {
 12182                'O' => '0',
 14183                'I' or 'L' => '1',
 1206184                _ => character
 1232185            };
 186
 1232187            if (!Alphabet.Contains(decoded))
 188            {
 2189                return null;
 190            }
 191
 1230192            canonical.Append(decoded);
 193        }
 194
 76195        return Digest(canonical.ToString());
 196    }
 197
 198    /// <summary>
 199    ///     An eight-character code issued before: rebuilt as the exact <c>XXXX-XXXX</c> it was stored
 200    ///     as, with no aliases, since its alphabet had every letter.
 201    /// </summary>
 202    private static byte[]? LegacyDigest(string stripped) =>
 58203        stripped.All(char.IsAsciiLetterOrDigit)
 58204            ? Digest($"{stripped[..GroupLength]}-{stripped[GroupLength..]}")
 58205            : null;
 206
 693207    private static byte[] Digest(string value) => SHA256.HashData(Encoding.UTF8.GetBytes(value));
 208}