OTP-0.1.0.0: src/Data/OTP.hs
-- | SPDX-License-Identifier: MIT
--
-- Implements the /HMAC-Based One-Time Password Algorithm/ (HOTP) as
-- defined in [RFC 4226](https://tools.ietf.org/html/rfc4226)
-- and the /Time-Based One-Time Password Algorithm/ (TOTP) as defined
-- in [RFC 6238](https://tools.ietf.org/html/rfc6238).
--
-- Many operations in this module take or return a 'Word32' OTP value
-- (whose most significant bit is always 0) which is truncated modulo
-- @10^digits@ according to the 'Word8' /digits/
-- parameter. Consequently, passing a value above 10 won't produce
-- more than 10 digits and will effectively return the raw
-- non-truncated 31-bit OTP value.
--
-- @since 0.1.0.0
module Data.OTP
( -- * HOTP
hotp
, hotpCheck
-- * TOTP
, totp
, totpCheck
-- * Auxiliary
, totpCounter
, counterRange
, totpCounterRange
, HashAlgorithm(..)
, Secret
) where
import Data.Bits
import qualified Data.ByteString as BS
import Data.Time
import Data.Time.Clock.POSIX
import Data.Word
import HashImpl
{- | Compute /HMAC-Based One-Time Password/ using secret key and counter value.
>>> hotp SHA1 "1234" 100 6
317569
>>> hotp SHA512 "1234" 100 6
134131
>>> hotp SHA512 "1234" 100 8
55134131
-}
hotp
:: HashAlgorithm -- ^ Hashing algorithm
-> Secret -- ^ Shared secret
-> Word64 -- ^ Counter value
-> Word8 -- ^ Number of base10 digits in HOTP value
-> Word32 -- ^ HOTP value
hotp alg key cnt digits
| digits >= 10 = snum
| otherwise = snum `rem` (10 ^ digits)
where
-- C
msg = bsFromW64 cnt
-- Snum = StToNum(Sbits)
-- Sbits = DT(HS)
-- HS = HMAC(K,C)
snum = trunc $ hmac alg key msg
-- DT(HS)
trunc :: BS.ByteString -> Word32
trunc b = case bsToW32 rb of
Left e -> error e
Right res -> res .&. (0x80000000 - 1) -- reset highest bit
where
offset = BS.last b .&. 15 -- take low 4 bits of last byte
rb = BS.take 4 $ BS.drop (fromIntegral offset) b -- resulting 4 byte value
-- StToNum(Sbits)
bsToW32 :: BS.ByteString -> Either String Word32
bsToW32 bs = case BS.unpack bs of
[ b0, b1, b2, b3 ] -> Right $! (((((fI b0 `shiftL` 8) .|. fI b1) `shiftL` 8) .|. fI b2) `shiftL` 8) .|. fI b3
_ -> Left "bsToW32: the impossible happened"
where
fI = fromIntegral
bsFromW64 :: Word64 -> BS.ByteString
bsFromW64 w = BS.pack [ b j | j <- [ 7, 6 .. 0 ] ]
where
b j = fromIntegral (w `shiftR` (j*8))
{- | Check presented password against a valid range.
>>> hotp SHA1 "1234" 10 6
50897
>>> hotpCheck SHA1 "1234" (0,0) 10 6 50897
True
>>> hotpCheck SHA1 "1234" (0,0) 9 6 50897
False
>>> hotpCheck SHA1 "1234" (0,1) 9 6 50897
True
>>> hotpCheck SHA1 "1234" (1,0) 11 6 50897
True
>>> hotpCheck SHA1 "1234" (2,2) 8 6 50897
True
>>> hotpCheck SHA1 "1234" (2,2) 7 6 50897
False
>>> hotpCheck SHA1 "1234" (2,2) 12 6 50897
True
>>> hotpCheck SHA1 "1234" (2,2) 13 6 50897
False
-}
hotpCheck
:: HashAlgorithm -- ^ Hash algorithm to use
-> Secret -- ^ Shared secret
-> (Word8, Word8) -- ^ Valid counter range, before and after ideal
-> Word64 -- ^ Ideal (expected) counter value
-> Word8 -- ^ Number of base10 digits in a password
-> Word32 -- ^ Password (i.e. HOTP value) entered by user
-> Bool -- ^ True if password is valid
hotpCheck alg secr rng cnt len pass =
let counters = counterRange rng cnt
passwds = map (\c -> hotp alg secr c len) counters
in any (pass ==) passwds
{- | Compute a /Time-Based One-Time Password/ using secret key and time.
>>> totp SHA1 "1234" (read "2010-10-10 00:01:00 UTC") 30 6
388892
>>> totp SHA1 "1234" (read "2010-10-10 00:01:00 UTC") 30 8
43388892
>>> totp SHA1 "1234" (read "2010-10-10 00:01:15 UTC") 30 8
43388892
>>> totp SHA1 "1234" (read "2010-10-10 00:01:31 UTC") 30 8
39110359
-}
totp
:: HashAlgorithm -- ^ Hash algorithm to use
-> Secret -- ^ Shared secret
-> UTCTime -- ^ Time of TOTP
-> Word64 -- ^ Time range in seconds
-> Word8 -- ^ Number of base10 digits in TOTP value
-> Word32 -- ^ TOTP value
totp alg secr time period len =
hotp alg secr (totpCounter time period) len
{- | Check presented password against time periods.
>>> totp SHA1 "1234" (read "2010-10-10 00:00:00 UTC") 30 6
778374
>>> totpCheck SHA1 "1234" (0, 0) (read "2010-10-10 00:00:00 UTC") 30 6 778374
True
>>> totpCheck SHA1 "1234" (0, 0) (read "2010-10-10 00:00:30 UTC") 30 6 778374
False
>>> totpCheck SHA1 "1234" (1, 0) (read "2010-10-10 00:00:30 UTC") 30 6 778374
True
>>> totpCheck SHA1 "1234" (1, 0) (read "2010-10-10 00:01:00 UTC") 30 6 778374
False
>>> totpCheck SHA1 "1234" (2, 0) (read "2010-10-10 00:01:00 UTC") 30 6 778374
True
-}
totpCheck
:: HashAlgorithm -- ^ Hash algorithm to use
-> Secret -- ^ Shared secret
-> (Word8, Word8) -- ^ Valid counter range, before and after ideal
-> UTCTime -- ^ Time of TOTP
-> Word64 -- ^ Time range in seconds
-> Word8 -- ^ Number of base10 digits in a password
-> Word32 -- ^ Password given by user
-> Bool -- ^ True if password is valid
totpCheck alg secr rng time period len pass =
let counters = totpCounterRange rng time period
passwds = map (\c -> hotp alg secr c len) counters
in any (pass ==) passwds
{- | Calculate HOTP counter using time. Starting time (T0
according to RFC6238) is 0 (begining of UNIX epoch)
>>> totpCounter (read "2010-10-10 00:00:00 UTC") 30
42888960
>>> totpCounter (read "2010-10-10 00:00:30 UTC") 30
42888961
>>> totpCounter (read "2010-10-10 00:01:00 UTC") 30
42888962
-}
totpCounter
:: UTCTime -- ^ Time of totp
-> Word64 -- ^ Time range in seconds
-> Word64 -- ^ Resulting counter
totpCounter time period =
let timePOSIX = floor $ utcTimeToPOSIXSeconds time
in timePOSIX `div` period
{- | Make a sequence of acceptable counters, protected from
arithmetic overflow.
>>> counterRange (0, 0) 9000
[9000]
>>> counterRange (1, 0) 9000
[8999,9000]
>>> length $ counterRange (5000, 0) 9000
501
>>> length $ counterRange (5000, 5000) 9000
1000
>>> counterRange (2, 2) maxBound
[18446744073709551613,18446744073709551614,18446744073709551615]
>>> counterRange (2, 2) minBound
[0,1,2]
>>> counterRange (2, 2) (maxBound `div` 2)
[9223372036854775805,9223372036854775806,9223372036854775807,9223372036854775808,9223372036854775809]
>>> counterRange (5, 5) 9000
[8995,8996,8997,8998,8999,9000,9001,9002,9003,9004,9005]
RFC recommends avoiding excessively large values for counter ranges.
-}
counterRange
:: (Word8, Word8) -- ^ Number of counters before and after ideal
-> Word64 -- ^ Ideal counter value
-> [Word64]
counterRange (tolow, tohigh) ideal = [l..h]
where
l' = ideal - fromIntegral tolow
l | l' <= ideal = l'
| otherwise = 0
h' = ideal + fromIntegral tohigh
h | ideal <= h' = h'
| otherwise = maxBound
{- | Make a sequence of acceptable periods.
>>> totpCounterRange (0, 0) (read "2010-10-10 00:01:00 UTC") 30
[42888962]
>>> totpCounterRange (2, 0) (read "2010-10-10 00:01:00 UTC") 30
[42888960,42888961,42888962]
>>> totpCounterRange (0, 2) (read "2010-10-10 00:01:00 UTC") 30
[42888962,42888963,42888964]
>>> totpCounterRange (2, 2) (read "2010-10-10 00:01:00 UTC") 30
[42888960,42888961,42888962,42888963,42888964]
-}
totpCounterRange :: (Word8, Word8)
-> UTCTime
-> Word64
-> [Word64]
totpCounterRange rng time period =
counterRange rng $ totpCounter time period