| | | 1 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// FR-AU-12: the per-account budget for second factors presented at sign-in (UC-38) — an |
| | | 5 | | /// authenticator-app code, an email code or a recovery code alike. |
| | | 6 | | /// </summary> |
| | | 7 | | /// <remarks> |
| | | 8 | | /// <para> |
| | | 9 | | /// <b>Why per account, and not per challenge.</b> The challenge's own budgets (FR-2F-13, |
| | | 10 | | /// FR-2F-17) start afresh with every login, and a login costs whoever knows the password |
| | | 11 | | /// nothing — which is the one caller a second factor exists to stop. So the failures are |
| | | 12 | | /// counted on the account, across every challenge, and a correct password does not reset |
| | | 13 | | /// them: only a second factor that checks out does. |
| | | 14 | | /// </para> |
| | | 15 | | /// <para> |
| | | 16 | | /// <b>Why the lock escalates.</b> A fixed lock caps guessing at a steady rate, and at ten |
| | | 17 | | /// guesses every fifteen minutes the third of a million guesses a six-digit code takes on |
| | | 18 | | /// average still fit in a year. Doubling each lock, up to a day, brings a persistent attacker |
| | | 19 | | /// down to ten guesses a day, while a person who mistypes ten times waits fifteen minutes, as |
| | | 20 | | /// they would at the password. The count of locks resets when a second factor succeeds. |
| | | 21 | | /// </para> |
| | | 22 | | /// <para> |
| | | 23 | | /// <b>Why the lock does not show.</b> A locked account answers UC-38's ordinary refusal, and |
| | | 24 | | /// the factor is still verified — the result discarded — so neither the message nor the cost |
| | | 25 | | /// of the answer tells the caller that guessing has stopped counting. |
| | | 26 | | /// </para> |
| | | 27 | | /// </remarks> |
| | | 28 | | public static class SecondFactorLockout |
| | | 29 | | { |
| | | 30 | | /// <summary>The failures that lock the second factor.</summary> |
| | | 31 | | public const int MaxFailedAttempts = 10; |
| | | 32 | | |
| | | 33 | | /// <summary>How long the first lock lasts. Each one after it lasts twice as long as the last.</summary> |
| | 2 | 34 | | public static readonly TimeSpan FirstLockout = TimeSpan.FromMinutes(15); |
| | | 35 | | |
| | | 36 | | /// <summary>The longest any lock lasts.</summary> |
| | 2 | 37 | | public static readonly TimeSpan LongestLockout = TimeSpan.FromHours(24); |
| | | 38 | | |
| | | 39 | | /// <summary> |
| | | 40 | | /// When each lock that started at <paramref name="now" /> would end, by how many locks came |
| | | 41 | | /// before it: the first, the second, and so on, the last entry standing for every lock from the |
| | | 42 | | /// cap on. The shape <c>IAtomicWrites.TryReserveSecondFactorAttemptAsync</c> takes. |
| | | 43 | | /// </summary> |
| | | 44 | | public static IReadOnlyList<DateTime> LockoutEndsFrom(DateTime now) |
| | | 45 | | { |
| | 120 | 46 | | var ends = new List<DateTime>(); |
| | | 47 | | |
| | 960 | 48 | | for (var lockout = FirstLockout; ; lockout *= 2) |
| | | 49 | | { |
| | 960 | 50 | | if (lockout >= LongestLockout) |
| | | 51 | | { |
| | 120 | 52 | | ends.Add(now + LongestLockout); |
| | | 53 | | |
| | 120 | 54 | | return ends; |
| | | 55 | | } |
| | | 56 | | |
| | 840 | 57 | | ends.Add(now + lockout); |
| | | 58 | | } |
| | | 59 | | } |
| | | 60 | | } |