{-| Module : Botan.Low.HOTP Description : Hash-based one-time passwords Copyright : (c) Leo D, 2023 License : BSD-3-Clause Maintainer : leo@apotheca.io Stability : experimental Portability : POSIX One time password schemes are a user authentication method that relies on a fixed secret key which is used to derive a sequence of short passwords, each of which is accepted only once. Commonly this is used to implement two-factor authentication (2FA), where the user authenticates using both a conventional password (or a public key signature) and an OTP generated by a small device such as a mobile phone. -} module Botan.Low.HOTP ( -- * Hash-based One Time Password -- $introduction -- * Usage -- $usage -- * HOTP HOTP(..) , HOTPHashName(..) , HOTPCounter(..) , HOTPCode(..) , withHOTP , hotpInit , hotpDestroy , hotpGenerate , hotpCheck -- * HOTP Hashes , pattern HOTP_SHA1 , pattern HOTP_SHA256 , pattern HOTP_SHA512 -- * Convenience , hotpHashes ) where import qualified Data.ByteString as ByteString import Botan.Bindings.HOTP import Botan.Low.Hash import Botan.Low.Error import Botan.Low.Make import Botan.Low.Prelude import Botan.Low.Remake -- NOTE: RFC 4226 -- NOTE: I think this *only* takes SHA-2, specificaly "SHA-256" and "SHA-512", -- and probably because the RFC specifically uses it? {- $introduction Botan implements the HOTP and TOTP schemes from RFC 4226 and 6238. Since the range of possible OTPs is quite small, applications must rate limit OTP authentication attempts to some small number per second. Otherwise an attacker could quickly try all 1000000 6-digit OTPs in a brief amount of time. HOTP generates OTPs that are a short numeric sequence, between 6 and 8 digits (most applications use 6 digits), created using the HMAC of a 64-bit counter value. If the counter ever repeats the OTP will also repeat, thus both parties must assure the counter only increments and is never repeated or decremented. Thus both client and server must keep track of the next counter expected. Anyone with access to the client-specific secret key can authenticate as that client, so it should be treated with the same security consideration as would be given to any other symmetric key or plaintext password. -} {- $usage > WARNING: Guarding against concurrent access to the stored counter is > beyond the scope of this tutorial. To use HOTP for MFA / 2FA, the client authenticator must generate a client-specific shared secret and counter value, and securely communicate them to the server authenticator. The secret key may be any bytestring value with more than 160 bits, such as a Bcrypt digest or SRP6 shared key. The counter value is not required to be a secret; it may start at 0 for simplicity, or it may start at a value that was selected at random. > import Botan.Low.HOTP > import Botan.Low.RNG > import Botan.Low.MPI > import Data.Bits > sharedSecret <- systemRNGGet 16 > -- TODO: Use random:System.Random.Stateful.Uniform instead of MPI in `botan` > (hi :: Word32) <- mpInit >>= \ w -> mpRandBits w rng 32 >> mpToWord32 w > (lo :: Word32) <- mpInit >>= \ w -> mpRandBits w rng 32 >> mpToWord32 w > (counter :: Word64) = shiftL (fromIntegral hi) 32 `xor` fromIntegral lo The client and server authenticators are now in a shared state, and any login attempts from a new device may be authenticated using HOTP as MFA. A client has requested a new connection, and HOTP is being used as MFA/2FA to authenticate their request. The server authenticator receives the client connection request and initializes a HOTP session using the stored client-specific shared secret, and then sends an authentication request to the client authenticator: > -- (serverSharedSecret, serverCounter) <- lookupServerSharedSecretAndCounter > serverSession <- hotpInit serverSharedSecret HOTP_SHA512 8 > -- sendMFAAuthenticationRequest The client authenticator receives the authentication request, generates a client-side code, increments their counter, and displays the HOTP code to the user: > -- (clientSharedSecret, clientCounter) <- lookupClientSharedSecretAndCounter > clientSession <- hotpInit clientSharedSecret HOTP_SHA512 8 > clientCode <- hotpGenerate clientSession clientCounter > -- incrementAndPersistClientCounter > -- displayClientCode clientCode > NOTE: The client authenticator is responsible for incrementing and persisting > their own counter manually. The client then sends the client code to the server authenticator using the unauthenticated / requested connection: > -- clientCode <- readClientCode > -- sendMFAAuthenticationResponse clientCode The server authenticator receives the authentication response, and performs a check of the key, with a resync range of R in case the client has generated a few codes without logging in successfully: > -- serverClientCode <- didreceiveMFAAuthenticationResponse > (isValid,nextCounter) <- hotpCheck serverSession serverClientCode serverCounter 10 > persistClientCounter nextCounter > NOTE: The server authentication check returns an incremented and resynced > counter which must be persisted manually. If the authentication check fails, > the counter value is return unchanged. If the code is valid, then the signin may be completed on the new connection as normal. The server should discontinue the session and refuse any new connections to the account after T unsuccessful authentication attempts, where T < R. The user should then be notified. -} newtype HOTP = MkHOTP { getHOTPForeignPtr :: ForeignPtr BotanHOTPStruct } newHOTP :: BotanHOTP -> IO HOTP withHOTP :: HOTP -> (BotanHOTP -> IO a) -> IO a hotpDestroy :: HOTP -> IO () createHOTP :: (Ptr BotanHOTP -> IO CInt) -> IO HOTP (newHOTP, withHOTP, hotpDestroy, createHOTP, _) = mkBindings MkBotanHOTP runBotanHOTP MkHOTP getHOTPForeignPtr botan_hotp_destroy type HOTPHashName = HashName pattern HOTP_SHA1 , HOTP_SHA256 , HOTP_SHA512 :: HOTPHashName pattern HOTP_SHA1 = SHA1 pattern HOTP_SHA256 = SHA256 pattern HOTP_SHA512 = SHA512 -- TODO: Do any other hashes work? hotpHashes = [ HOTP_SHA1 , HOTP_SHA256 , HOTP_SHA512 ] type HOTPCounter = Word64 type HOTPCode = Word32 -- NOTE: Digits should be 6-8 hotpInit :: ByteString -- ^ __key[]__ -> HashName -- ^ __hash_algo__ -> Int -- ^ __digits__ -> IO HOTP -- ^ __hotp__ hotpInit key algo digits = asBytesLen key $ \ keyPtr keyLen -> do asCString algo $ \ algoPtr -> do createHOTP $ \ out -> botan_hotp_init out (ConstPtr keyPtr) keyLen (ConstPtr algoPtr) (fromIntegral digits) -- WARNING: withFooInit-style limited lifetime functions moved to high-level botan withHOTPInit :: ByteString -> ByteString -> Int -> (HOTP -> IO a) -> IO a withHOTPInit = mkWithTemp3 hotpInit hotpDestroy -- NOTE: User is responsible for incrementing counter at this level hotpGenerate :: HOTP -- ^ __hotp__ -> HOTPCounter -- ^ __hotp_counter__ -> IO HOTPCode -- ^ __hotp_code__ hotpGenerate hotp counter = withHOTP hotp $ \ hotpPtr -> do alloca $ \ outPtr -> do throwBotanIfNegative $ botan_hotp_generate hotpPtr outPtr counter peek outPtr -- NOTE: -- "Returns a pair of (is_valid,next_counter_to_use). If the OTP is -- invalid then always returns (false,starting_counter), since the -- last successful authentication counter has not changed. "" -- NOTE: "Depending on the environment a resync_range of 3 to 10 might be appropriate." hotpCheck :: HOTP -- ^ __hotp__ -> HOTPCode -- ^ __hotp_code__ -> HOTPCounter -- ^ __hotp_counter__ -> Int -- ^ __resync_range__ -> IO (Bool, HOTPCounter) -- ^ __(valid,next_counter)__ hotpCheck hotp code counter resync = withHOTP hotp $ \ hotpPtr -> do alloca $ \ outPtr -> do valid <- throwBotanCatchingSuccess $ botan_hotp_check hotpPtr outPtr code counter (fromIntegral resync) nextCounter <- peek outPtr return (valid, nextCounter)