< Summary - Heimdall API

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

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
FindMatchingAsync()100%1212100%

File(s)

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

#LineLine coverage
 1using ArturRios.Data.Relational.Core.Interfaces;
 2using ArturRios.Heimdall.Domain.Entities;
 3using ArturRios.Heimdall.Domain.Persistence;
 4using Microsoft.EntityFrameworkCore;
 5
 6namespace ArturRios.Heimdall.Command.Services;
 7
 8/// <summary>
 9///     Matches a submitted email two-factor code against the live codes issued for a configuration,
 10///     and counts the guesses that miss so a code cannot be brute-forced (FR-2F-13).
 11/// </summary>
 12/// <remarks>
 13///     <para>
 14///         Shared by UC-37's confirmation and <see cref="ITwoFactorFactorVerifier" />, which had a
 15///         copy each. A static helper rather than another injected service because it holds no state
 16///         and no configuration — it is the comparison itself, and giving it an interface would only
 17///         add a registration for callers that always want exactly this behaviour.
 18///     </para>
 19///     <para>
 20///         <b>Why the counter.</b> The code is six digits, so a million values, and lives ten
 21///         minutes. The per-IP request limiter bounds one source's rate but not an attacker
 22///         distributed across many, which is cheap. Retiring a code after
 23///         <see cref="MaxFailedAttempts" /> misses caps what any single issued code can ever be worth
 24///         and makes further guessing cost a fresh login — which is itself limited, and which mails
 25///         the account holder a code they did not ask for.
 26///     </para>
 27/// </remarks>
 28public static class TwoFactorEmailCodeVerification
 29{
 30    /// <summary>Wrong guesses a single issued code tolerates before it is retired.</summary>
 31    public const int MaxFailedAttempts = 5;
 32
 33    /// <param name="emailCodeReader">Reads the configuration's outstanding codes.</param>
 34    /// <param name="atomicWrites">Charges each guess, and retires a code at the cap.</param>
 35    /// <param name="twoFactorAuthId">The configuration whose codes are considered.</param>
 36    /// <param name="code">The submitted code.</param>
 37    /// <returns>
 38    ///     The matching code, or <c>null</c> when none matches. A missing, incorrect, expired,
 39    ///     already-used, or exhausted code all answer alike — UC-37 and UC-38 distinguish none of
 40    ///     them, and a caller who could tell "wrong" from "no attempts left" would learn how much
 41    ///     budget remained. The caller spends the matching code itself, once its own checks pass.
 42    /// </returns>
 43    public static async Task<TwoFactorEmailCode?> FindMatchingAsync(
 44        IAsyncReadOnlyRepository<TwoFactorEmailCode, long> emailCodeReader,
 45        IAtomicWrites atomicWrites,
 46        long twoFactorAuthId,
 47        string? code)
 48    {
 6149        if (string.IsNullOrWhiteSpace(code))
 50        {
 551            return null;
 52        }
 53
 5654        var now = DateTime.UtcNow;
 55
 5656        var live = await emailCodeReader.Query()
 5657            .Where(x => x.TwoFactorAuthId == twoFactorAuthId && !x.Used && x.ExpiresAt > now)
 5658            .ToListAsync();
 59
 60        // A guess is charged to every code currently outstanding, not to one of them: there is no
 61        // single code it is "aimed at", and charging nothing would leave the budget unspent. In
 62        // practice a configuration holds one live code at a time — both issuing paths retire the
 63        // previous one first. A code whose budget is already spent, or that a parallel request has
 64        // just spent, is not compared at all.
 5665        var charged = new List<TwoFactorEmailCode>(live.Count);
 66
 20267        foreach (var outstanding in live)
 68        {
 4569            if (await atomicWrites.TryChargeEmailCodeGuessAsync(outstanding, MaxFailedAttempts, now))
 70            {
 4071                charged.Add(outstanding);
 72            }
 4573        }
 74
 75        // Through the gate, like every other Argon2id derivation on a request path (TH-03): the code
 76        // is hashed with the same 600 MB parameters as a password, so a comparison outside the bound
 77        // was memory the bound did not know about.
 17578        foreach (var candidate in charged)
 79        {
 4080            if (await PasswordHashGate.Shared.TextMatchesAsync(code, candidate.CodeHash, candidate.Salt))
 81            {
 1782                return candidate;
 83            }
 2384        }
 85
 86        // A miss: any code it brought to the cap is retired. Bookkeeping either way — the guess was
 87        // wrong, and neither use case defines a flow in which a caller is told it could not be kept.
 12288        foreach (var exhausted in charged)
 89        {
 2290            await atomicWrites.RetireEmailCodeIfExhaustedAsync(exhausted, MaxFailedAttempts);
 91        }
 92
 3993        return null;
 6194    }
 95}

Methods/Properties

FindMatchingAsync()