| | | 1 | | using System.Security.Cryptography; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 4 | | using OtpNet; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.Command.Services; |
| | | 7 | | |
| | | 8 | | /// <inheritdoc cref="ITotpCodeVerifier" /> |
| | 378 | 9 | | public class TotpCodeVerifier( |
| | 378 | 10 | | ITotpSecretProtector totpSecretProtector, |
| | 378 | 11 | | IAtomicWrites atomicWrites) : ITotpCodeVerifier |
| | | 12 | | { |
| | | 13 | | // A one time-step (30s) tolerance on either side of "now", the conventional allowance for clock |
| | | 14 | | // drift between the server and whatever device generated the code — wide enough to forgive a |
| | | 15 | | // little skew, narrow enough that it does not meaningfully widen the guessing window. |
| | 2 | 16 | | private static readonly VerificationWindow TotpVerificationWindow = new(previous: 1, future: 1); |
| | | 17 | | |
| | | 18 | | public async Task<bool> VerifyAsync(TwoFactorAuth twoFactorAuth, string? code) |
| | | 19 | | { |
| | 125 | 20 | | if (string.IsNullOrWhiteSpace(code) || twoFactorAuth.TotpSecretEncrypted is null) |
| | | 21 | | { |
| | 19 | 22 | | return false; |
| | | 23 | | } |
| | | 24 | | |
| | | 25 | | string base32Secret; |
| | | 26 | | |
| | | 27 | | try |
| | | 28 | | { |
| | 106 | 29 | | base32Secret = totpSecretProtector.Unprotect(twoFactorAuth.TotpSecretEncrypted); |
| | 90 | 30 | | } |
| | 16 | 31 | | catch (CryptographicException) |
| | | 32 | | { |
| | | 33 | | // A corrupted or otherwise unreadable TotpSecretEncrypted value (e.g. protected under a |
| | | 34 | | // Data Protection key that is no longer available) can never be decrypted back into a |
| | | 35 | | // valid app code, so it fails the same way a wrong code does instead of surfacing as a |
| | | 36 | | // 500. |
| | 16 | 37 | | return false; |
| | | 38 | | } |
| | | 39 | | |
| | 90 | 40 | | var totp = new Totp(Base32Encoding.ToBytes(base32Secret)); |
| | | 41 | | |
| | 90 | 42 | | if (!totp.VerifyTotp(code, out var matchedTimeStep, TotpVerificationWindow)) |
| | | 43 | | { |
| | 45 | 44 | | return false; |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | // FR-2F-14 / RFC 6238 §5.2: a code is good once. The window above accepts the neighbouring |
| | | 48 | | // steps too, so without this an observed code stays usable for up to ninety seconds and |
| | | 49 | | // across every endpoint that takes a second factor — verify, disable, and regenerate alike. |
| | | 50 | | // Strictly-greater rather than not-equal, so a replay of an older step inside the window is |
| | | 51 | | // refused as well. |
| | | 52 | | // |
| | | 53 | | // The comparison is made by the database, in the write that records the step: two requests |
| | | 54 | | // carrying the same code at the same moment both pass a check made in memory, and both used |
| | | 55 | | // to be accepted. Only one of them can advance the step, and only that one is accepted. A |
| | | 56 | | // failure to record it is a failure to verify for the same reason — accepting a code whose |
| | | 57 | | // use was not recorded would leave it replayable. |
| | 45 | 58 | | return await atomicWrites.TryAdvanceTotpStepAsync(twoFactorAuth, matchedTimeStep, DateTime.UtcNow); |
| | 125 | 59 | | } |
| | | 60 | | } |