packages feed

otp-authenticator-0.1.2.0: src/Authenticator/Vault.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE NoImplicitPrelude #-}

-- |
-- Module      : Authenticator.Vault
-- Description : Secrets and storage for OTP keys.
-- Copyright   : (c) Justin Le 2017
-- License     : MIT
-- Maintainer  : justin@jle.im
-- Stability   : unstable
-- Portability : portable
--
-- Types for storing, serializing, accessing OTP keys.  Gratuitous
-- type-level programming here for no reason because I have issues.
--
-- Based off of <https://github.com/google/google-authenticator>.
module Authenticator.Vault
  ( Mode (..),
    SMode (..),
    withSMode,
    fromSMode,
    HashAlgo (..),
    parseAlgo,
    Secret (..),
    OTPDigits (..),
    pattern OTPDigitsInt,
    ModeState (..),
    SomeSecretState,
    Vault (..),
    _Vault,
    hotp,
    totp,
    totp_,
    otp,
    someSecret,
    vaultSecrets,
    describeSecret,
    secretURI,
    parseSecretURI,
  )
where

import Authenticator.Common
import qualified Codec.Binary.Base32 as B32
import Control.Applicative
import Control.Monad hiding (fail)
import Crypto.Hash.Algorithms
import qualified Crypto.OTP as OTP
import qualified Data.Aeson as J
import Data.Bifunctor
import qualified Data.Binary as B
import Data.Bitraversable
import qualified Data.ByteString as BS
import Data.Char
import Data.Dependent.Sum
import Data.Function
import Data.GADT.Show
import Data.Kind
import qualified Data.Map as M
import Data.Maybe
import Data.Ord
import qualified Data.Set as S
import Data.Some
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import Data.Time.Clock.POSIX
import Data.Vinyl
import Data.Void
import Data.Word
import GHC.Generics
import qualified Network.URI.Encode as U
import Prelude.Compat
import qualified Text.Megaparsec as P
import qualified Text.Megaparsec.Char as P
import Text.Printf
import Text.Read (readMaybe)

-- | OTP generation mode
data Mode
  = -- | Counter-based
    HOTP
  | -- | Time-based
    TOTP
  deriving (Generic, Show)

-- | Singleton for 'Mode'
data SMode :: Mode -> Type where
  SHOTP :: SMode 'HOTP
  STOTP :: SMode 'TOTP

deriving instance Show (SMode m)

instance GShow SMode where
  gshowsPrec = showsPrec

instance B.Binary Mode

instance J.ToJSON Mode where
  toJSON HOTP = J.toJSON @T.Text "hotp"
  toJSON TOTP = J.toJSON @T.Text "totp"

-- | Reify a 'Mode' to its singleton
withSMode ::
  Mode ->
  (forall m. SMode m -> r) ->
  r
withSMode = \case
  HOTP -> ($ SHOTP)
  TOTP -> ($ STOTP)

-- | Reflect a 'SMode' to its value.
fromSMode :: SMode m -> Mode
fromSMode = \case
  SHOTP -> HOTP
  STOTP -> TOTP

-- | A data family consisting of the state required by each mode.
data family ModeState :: Mode -> Type

-- | For 'HOTP' (counter-based) mode, the state is the current counter.
data instance ModeState 'HOTP = HOTPState {hotpCounter :: Word64}
  deriving (Generic, Show)

-- | For 'TOTP' (time-based) mode, there is no state.
data instance ModeState 'TOTP = TOTPState
  deriving (Generic, Show)

instance B.Binary (ModeState 'HOTP)

instance B.Binary (ModeState 'TOTP)

instance J.ToJSON (ModeState 'HOTP) where
  toEncoding HOTPState {..} = J.pairs $ "counter" J..= hotpCounter
  toJSON HOTPState {..} =
    J.object
      ["counter" J..= hotpCounter]

instance J.ToJSON (ModeState 'TOTP)

modeStateBinary :: SMode m -> DictOnly B.Binary (ModeState m)
modeStateBinary = \case
  SHOTP -> DictOnly
  STOTP -> DictOnly

-- | Which OTP-approved hash algorithm to use?
data HashAlgo = HASHA1 | HASHA256 | HASHA512
  deriving (Generic, Show)

instance B.Binary HashAlgo

instance J.ToJSON HashAlgo where
  toJSON HASHA1 = J.toJSON @T.Text "sha1"
  toJSON HASHA256 = J.toJSON @T.Text "sha256"
  toJSON HASHA512 = J.toJSON @T.Text "sha512"

-- | Generate the /cryptonite/ 'HashAlgorithm' instance.
hashAlgo :: HashAlgo -> Some (Dict HashAlgorithm)
hashAlgo HASHA1 = Some $ Dict SHA1
hashAlgo HASHA256 = Some $ Dict SHA256
hashAlgo HASHA512 = Some $ Dict SHA512

-- | Parse a hash algorithm string into the appropriate 'HashAlgo'.
parseAlgo :: String -> Maybe HashAlgo
parseAlgo = (`lookup` algos) . map toLower . unwords . words
  where
    algos =
      [ ("sha1", HASHA1),
        ("sha256", HASHA256),
        ("sha512", HASHA512)
      ]

-- | Newtype wrapper to provide 'Eq', 'Ord', 'B.Binary', and 'J.ToJSON'
-- instances.  You can convert to and from this and the 'Int'
-- representation using 'OTPDigitsInt'
newtype OTPDigits = OTPDigits {otpDigits :: OTP.OTPDigits}
  deriving (Show)

instance Eq OTPDigits where
  (==) = (==) `on` show

instance Ord OTPDigits where
  compare = comparing show

otpDigitsSet :: S.Set OTPDigits
otpDigitsSet =
  S.fromList $
    OTPDigits <$> [OTP.OTP4, OTP.OTP5, OTP.OTP6, OTP.OTP7, OTP.OTP8, OTP.OTP9]

pattern OTPDigitsInt :: OTPDigits -> Int
pattern OTPDigitsInt o <- ((`safeElemAt` otpDigitsSet) . subtract 4 -> Just o)
  where
    OTPDigitsInt o = S.findIndex o otpDigitsSet + 4

instance B.Binary OTPDigits where
  get = do
    OTPDigitsInt o <- B.get
    pure o
  put = B.put . OTPDigitsInt

instance J.ToJSON OTPDigits where
  toEncoding = J.toEncoding . OTPDigitsInt
  toJSON = J.toJSON . OTPDigitsInt

-- | A standards-compliant secret key type.  Well, almost.  It doesn't
-- include configuration for the time period if it's time-based.
data Secret :: Mode -> Type where
  Sec ::
    { secAccount :: T.Text,
      secIssuer :: Maybe T.Text,
      secAlgo :: HashAlgo,
      secDigits :: OTPDigits,
      secKey :: BS.ByteString
    } ->
    Secret m
  deriving (Generic, Show)

instance B.Binary (Secret m)

instance J.ToJSON (Secret m) where
  toEncoding Sec {..} =
    J.pairs
      ( "account" J..= secAccount
          <> maybe mempty ("issuer" J..=) secIssuer
          <> "algorithm" J..= secAlgo
          <> "digits" J..= secDigits
          <> "key" J..= formatKey 4 (T.decodeUtf8 (B32.encode secKey))
      )
  toJSON Sec {..} =
    J.object $
      [ "account" J..= secAccount,
        "algorithm" J..= secAlgo,
        "digits" J..= secDigits,
        "key" J..= formatKey 4 (T.decodeUtf8 (B32.encode secKey))
      ]
        ++ maybe [] ((: []) . ("issuer" J..=)) secIssuer

formatKey ::
  -- | chunk size
  Int ->
  T.Text ->
  T.Text
formatKey c =
  T.unwords
    . T.chunksOf c
    . T.map toLower
    . T.filter isAlphaNum

-- | Print out the metadata (account name and issuer) of a 'Secret'.
describeSecret ::
  Secret m ->
  T.Text
describeSecret s =
  secAccount s <> case secIssuer s of
    Nothing -> ""
    Just i -> " / " <> i

instance B.Binary SomeSecretState where
  get = do
    m <- B.get
    withSMode m $ \s -> case modeStateBinary s of
      DictOnly -> do
        sc <- B.get
        ms <- B.get
        return $ s :=> sc :*: ms
  put = \case
    s :=> sc :*: ms -> case modeStateBinary s of
      DictOnly -> do
        B.put $ fromSMode s
        B.put sc
        B.put ms

instance J.ToJSON SomeSecretState where
  toEncoding (s :=> sc :*: ms) =
    J.pairs
      ( "type" J..= fromSMode s
          <> "secret" J..= sc
          <> ( case s of
                 SHOTP -> "state" J..= ms
                 STOTP -> mempty
             )
      )
  toJSON (s :=> sc :*: ms) =
    J.object $
      [ "type" J..= fromSMode s,
        "secret" J..= sc
      ]
        ++ case s of
          SHOTP -> ["state" J..= ms]
          STOTP -> []

-- | A 'Secret' coupled with its 'ModeState', existentially quantified over
-- its 'Mode'.
type SomeSecretState = DSum SMode (Secret :*: ModeState)

-- | A list of secrets and their states, of various modes.
newtype Vault = Vault {vaultList :: [SomeSecretState]}
  deriving (Generic)

instance B.Binary Vault

instance J.ToJSON Vault where
  toEncoding l = J.pairs $ "vault" J..= vaultList l
  toJSON l = J.object ["vault" J..= vaultList l]

-- | Generate an HTOP (counter-based) code, returning a modified state.
hotp :: Secret 'HOTP -> ModeState 'HOTP -> (T.Text, ModeState 'HOTP)
hotp Sec {..} (HOTPState i) =
  (formatKey 3 . T.pack $ printf fmt p, HOTPState (i + 1))
  where
    fmt = "%0" ++ show (OTPDigitsInt secDigits) ++ "d"
    p = withSome (hashAlgo secAlgo) $ \case
      Dict a -> OTP.hotp a (otpDigits secDigits) secKey i

-- | (Purely) generate a TOTP (time-based) code, for a given time.
totp_ :: Secret 'TOTP -> POSIXTime -> T.Text
totp_ Sec {..} t = withSome (hashAlgo secAlgo) $ \case
  Dict a ->
    let tparam = case OTP.mkTOTPParams a 0 30 (otpDigits secDigits) OTP.TwoSteps of
          Left e -> error $ "totp_: " <> e
          Right x -> x
     in formatKey 3 . T.pack $
          printf fmt $
            OTP.totp tparam secKey (round t)
  where
    fmt = "%0" ++ show (OTPDigitsInt secDigits) ++ "d"

-- | Generate a TOTP (time-based) code in IO for the current time.
totp :: Secret 'TOTP -> IO T.Text
totp s = totp_ s <$> getPOSIXTime

-- | Abstract over both 'hotp' and 'totp'.
otp :: SMode m -> Secret m -> ModeState m -> IO (T.Text, ModeState m)
otp = \case
  SHOTP -> curry $ return . uncurry hotp
  STOTP -> curry $ bitraverse totp return

-- | Some sort of RankN lens and traversal over a 'SomeSecret'.  Allows you
-- to traverse (effectfully map) over the 'ModeState' in
-- a 'SomeSecretState', with access to the 'Secret' as well.
--
-- With this you can implement getters and setters.  It's also used by the
-- library to update the 'ModeState' in IO.
someSecret ::
  (Functor f) =>
  (forall m. SMode m -> Secret m -> ModeState m -> f (ModeState m)) ->
  SomeSecretState ->
  f SomeSecretState
someSecret f = \case
  s :=> (sc :*: ms) -> (s :=>) . (sc :*:) <$> f s sc ms

-- | A RankN traversal over all of the 'Secret's and 'ModeState's in
-- a 'Vault'.
vaultSecrets ::
  (Applicative f) =>
  (forall m. SMode m -> Secret m -> ModeState m -> f (ModeState m)) ->
  Vault ->
  f Vault
vaultSecrets f = (_Vault . traverse) (someSecret f)

-- | A lens into the list of 'SomeSecretState's in a 'Vault'.  Should be an
-- Iso but we don't want a lens dependency now, do we.
_Vault ::
  (Functor f) =>
  ([SomeSecretState] -> f [SomeSecretState]) ->
  Vault ->
  f Vault
_Vault f s = Vault <$> f (vaultList s)

type Parser = P.Parsec Void String

-- | A parser for a otpauth URI.
secretURI :: Parser SomeSecretState
secretURI = do
  _ <- P.string "otpauth://"
  m <- otpMode
  _ <- P.char '/'
  (a, i) <- otpLabel
  ps <- M.fromList <$> P.try param `P.sepBy` P.char '&'
  sec <- case M.lookup "secret" ps of
    Nothing -> fail "Required parameter 'secret' not present"
    Just s ->
      case decodePad s of
        Just s' -> return s'
        Nothing -> fail $ "Not a valid base-32 string: " ++ T.unpack s
  let dig = fromMaybe (OTPDigits OTP.OTP6) $ do
        d <- M.lookup "digits" ps
        OTPDigitsInt o <- readMaybe $ T.unpack d
        pure o
      i' = i <|> M.lookup "issuer" ps
      alg = fromMaybe HASHA1 $ do
        al <- M.lookup "algorithm" ps
        parseAlgo . T.unpack . T.map toLower $ al
      secr :: forall m. Secret m
      secr = Sec a i' alg dig sec

  withSMode m $ \case
    SHOTP -> case M.lookup "counter" ps of
      Nothing -> fail "Paramater 'counter' required for hotp mode"
      Just (T.unpack -> c) -> case readMaybe c of
        Nothing -> fail $ "Could not parse 'counter' parameter: " ++ c
        Just c' -> return $ SHOTP :=> secr :*: HOTPState c'
    STOTP -> return $ STOTP :=> secr :*: TOTPState
  where
    otpMode :: Parser Mode
    otpMode =
      HOTP <$ P.string "hotp"
        <|> HOTP <$ P.string "HOTP"
        <|> TOTP <$ P.string "totp"
        <|> TOTP <$ P.string "TOTP"
    otpLabel :: Parser (T.Text, Maybe T.Text)
    otpLabel = do
      x <- P.some (P.try (mfilter (/= ':') uriChar))
      rest <-
        Just
          <$> ( colon
                  *> P.manyTill (P.try uriChar <|> uriSpace) (P.char '?')
              )
          <|> Nothing <$ P.char '?'
      return $ case rest of
        Nothing -> (T.pack . U.decode $ x, Nothing)
        Just y -> (T.pack . U.decode $ y, Just . T.pack . U.decode $ x)
    param :: Parser (T.Text, T.Text)
    param = do
      k <- T.map toLower . T.pack <$> P.some (P.try uriChar)
      _ <- P.char '='
      v <- T.pack <$> P.some (P.try uriChar)
      return (k, v)
    uriChar =
      P.try (P.satisfy U.isAllowed)
        <|> P.char '@'
        <|> ( do
                x <- U.decode <$> sequence [P.char '%', P.hexDigitChar, P.hexDigitChar]
                case x of
                  [y] -> return y
                  _ -> fail "Invalid URI escape code"
            )
    colon = void (P.char ':') <|> void (P.string "%3A")
    uriSpace = ' ' <$ (void P.space <|> void (P.string "%20"))

-- | Parse a valid otpauth URI and initialize its state.
--
-- See <https://github.com/google/google-authenticator/wiki/Key-Uri-Format>
parseSecretURI ::
  String ->
  Either String SomeSecretState
parseSecretURI s =
  first P.errorBundlePretty $
    P.parse secretURI "secret URI" s

safeElemAt :: Int -> S.Set a -> Maybe a
safeElemAt i s
  | i < S.size s = Just (S.elemAt i s)
  | otherwise = Nothing