packages feed

otp-authenticator-0.1.2.1: src/Authenticator/Actions.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- |
-- Module      : Authenticator.Actions
-- Description : Simple CLI actions for 'Vault's.
-- Copyright   : (c) Justin Le 2017
-- License     : MIT
-- Maintainer  : justin@jle.im
-- Stability   : unstable
-- Portability : portable
--
-- Basic actions to manipulate 'Vault's.  The @otp-auth@ executable is
-- a thin wrapper over these actions.
--
-- See 'Cmd'.
module Authenticator.Actions
  ( viewVault,
    addSecret,
    genSecret,
    editSecret,
    deleteSecret,
  )
where

import Authenticator.Common
import Authenticator.Vault
import Control.Monad
import Control.Monad.IO.Class
import Control.Monad.Trans.Class
import Control.Monad.Trans.Maybe
import Control.Monad.Trans.State
import Control.Monad.Trans.Writer
import qualified Crypto.OTP as OTP
import qualified Data.Aeson as J
import qualified Data.Aeson.Encoding as J
import qualified Data.ByteString.Lazy as BSL
import Data.Char
import Data.Dependent.Sum
import Data.Foldable
import Data.Functor
import Data.Maybe
import Data.Monoid
import Data.String
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import GHC.Generics
import Lens.Micro
import Options.Applicative
import qualified System.Console.Haskeline as L
import System.Exit
import Text.Printf
import Text.Read (readMaybe)
import Prelude hiding (filter)

data ViewOut = ViewOut
  { voId :: Int,
    voAccount :: T.Text,
    voIssuer :: Maybe T.Text,
    voValue :: Maybe T.Text,
    voMode :: Mode
  }

instance J.ToJSON ViewOut where
  toEncoding ViewOut {..} =
    J.pairs
      ( "id" J..= voId
          <> "account" J..= voAccount
          <> maybe mempty ("issuer" J..=) voIssuer
          <> maybe mempty ("value" J..=) voValue
          <> "mode" J..= voMode
      )
  toJSON ViewOut {..} =
    J.object $
      [ "id" J..= voId,
        "account" J..= voAccount,
        "mode" J..= voMode
      ]
        ++ maybe [] ((: []) . ("issuer" J..=)) voIssuer
        ++ maybe [] ((: []) . ("value" J..=)) voValue

-- | View secrets, generating codes for time-based keys.
viewVault ::
  -- | List key names only; do not generate any codes.
  Bool ->
  -- | json output
  Bool ->
  -- | Filter by ID or possibly by account name and issuer
  Either Int (Maybe T.Text, Maybe T.Text) ->
  Vault ->
  IO ()
viewVault l j filts vt = do
  (n, res) <- runWriterT . (`execStateT` 1) $ (`vaultSecrets` vt) $ \m sc ms -> do
    i <- state $ \x -> (x :: Int, x + 1)
    fmap (fromMaybe ms) . runMaybeT $ do
      case filts of
        Left n -> guard (i == n)
        Right (fAcc, fIss) -> do
          traverse_ (guard . (== secAccount sc)) fAcc
          traverse_ (guard . (== secIssuer sc) . Just) fIss
      val <- case m of
        STOTP | not l -> Just <$> liftIO (totp sc)
        _ -> pure Nothing
      lift . lift . tell . (: []) $
        ViewOut
          { voId = i,
            voAccount = secAccount sc,
            voIssuer = secIssuer sc,
            voValue = val,
            voMode = fromSMode m
          }
      return ms
  if j
    then
      T.putStrLn . T.decodeUtf8 . BSL.toStrict . J.encodingToLazyByteString $
        J.pairs
          ( "total" J..= (n - 1)
              <> "values" J..= res
          )
    else do
      printf "Searched %d total entries.\n" (n - 1)
      forM_ res $ \ViewOut {..} ->
        let described =
              voAccount <> case voIssuer of
                Nothing -> ""
                Just i -> " / " <> i
         in case voMode of
              HOTP -> printf "(%d) %s: [ counter-based, use gen ]\n" voId described
              TOTP -> printf "(%d) %s%s\n" voId described $ case voValue of
                Nothing -> ""
                Just v -> ": " <> v
      when (null res) $ case filts of
        Left i -> printf "ID %d not found!\n" i *> exitFailure
        Right _ -> putStrLn "No matches found!"

-- | Add a secret, interactively.
addSecret ::
  -- | Echo back password entry
  Bool ->
  -- | If 'True', add via otpauth protocol URI
  Bool ->
  Vault ->
  IO Vault
addSecret echoPass u vt = do
  -- TODO: verify b32?
  dsc <-
    if u
      then do
        q <-
          L.runInputT hlSettings $
            fromMaybe ""
              <$> if echoPass
                then L.getInputLine "URI Secret?: "
                else L.getPassword (Just '*') "URI Secret?: "
        putStrLn "parsing"
        case parseSecretURI q of
          Left err -> do
            putStrLn "Parse error:"
            putStrLn err
            exitFailure
          Right d ->
            return d
      else mkSecret echoPass
  putStrLn "Added succesfully!"
  return $
    vt & _Vault %~ (++ [dsc])

-- | Generate a secret code, forcing a new HOTP code if it is
-- counter-based.
genSecret ::
  -- | ID # of secret to generate
  Int ->
  Vault ->
  IO (Maybe (String, Vault))
genSecret n vt = do
  res <- runMaybeT . runWriterT . forOf (_Vault . ix (n - 1)) vt $ \case
    s :=> sc :*: ms ->
      case s of
        SHOTP -> do
          let (p, ms') = hotp sc ms
              out = printf "(%d) %s: %s\n" n (describeSecret sc) p
          tell (First (Just out))
          return $ s :=> sc :*: ms'
        STOTP -> do
          liftIO $ do
            p <- totp sc
            printf "(%d) %s: %s\n" n (describeSecret sc) p
          empty
  forM res $ \(r, changed) ->
    case getFirst changed of
      Just msg -> return (msg, r)
      Nothing -> do
        printf "No item with ID %d found.\n" n
        exitFailure

-- | Edit a secret's metadata (account name, issuer)
editSecret ::
  -- | ID # of secret to edit
  Int ->
  Vault ->
  IO Vault
editSecret n vt = do
  (vt', found) <- runWriterT . forOf (_Vault . ix (n - 1)) vt $ \case
    (s :=> sc :*: ms) -> do
      sc' <- liftIO $ do
        printf "Editing (%d) %s ...\n" n (describeSecret sc)
        liftIO (mkSecretFrom sc)
      tell (First (Just (describeSecret sc')))
      return $ s :=> sc' :*: ms
  case getFirst found of
    Nothing -> do
      printf "No item with ID %d found.\n" n
      exitFailure
    Just desc -> do
      printf "%s edited successfuly!\n" desc
      return vt'

-- | Delete a secret.
deleteSecret ::
  -- | ID # of secret to delete
  Int ->
  Vault ->
  IO Vault
deleteSecret n vt = do
  (vt', found) <- runWriterT . flip evalStateT 1 . forOf (_Vault . wither) vt $ \case
    ds@(_ :=> sc :*: _) -> do
      i <- state $ \x -> (x :: Int, x + 1)
      if n == i
        then do
          lift $ tell (Any True)
          a <-
            liftIO . L.runInputT hlSettings $
              L.getInputChar (printf "Delete %s? y/[n]: " (describeSecret sc))
          case toLower <$> a of
            Just 'y' -> do
              liftIO $ putStrLn "Deleted!"
              return Nothing
            _ -> return (Just ds)
        else return (Just ds)
  unless (getAny found) $ do
    printf "No item with ID %d found.\n" n
    exitFailure
  return vt'
  where
    wither f = fmap catMaybes . traverse f

mkSecret :: Bool -> IO SomeSecretState
mkSecret echoPass = L.runInputT hlSettings $ do
  a <-
    (mfilter (not . null) <$> L.getInputLine "Account?: ") >>= \case
      Nothing -> liftIO $ putStrLn "Account required" >> exitFailure
      Just r -> return r
  i <- L.getInputLine "Issuer? (optional): "
  k <-
    fromMaybe ""
      <$> if echoPass
        then L.getInputLine "Secret?: "
        else L.getPassword (Just '*') "Secret?: "
  m <- L.getInputChar "[t]ime- or (c)ounter-based?: "
  let i' = mfilter (not . null) i
      k' = decodePad . T.pack $ k
      s :: Maybe (Secret m)
      s = Sec (T.pack a) (T.pack <$> i') HASHA1 (OTPDigits OTP.OTP6) <$> k'
  case toLower <$> m of
    Just 'c' -> do
      n <- mfilter (not . null) <$> L.getInputLine "Initial counter? [0]: "
      n' <- case n of
        Nothing -> return 0
        Just n' -> case readMaybe n' of
          Just r -> return r
          Nothing -> liftIO $ putStrLn "Invalid initial counter.  Using 0." $> 0
      case s of
        Nothing -> liftIO $ do
          printf "Invalid secret key: %s\n" k
          exitFailure
        Just s' -> return $ SHOTP :=> s' :*: HOTPState n'
    _ -> case s of
      Nothing -> liftIO $ do
        printf "Invalid secret key: %s\n" k
        exitFailure
      Just s' -> return $ STOTP :=> (s' :*: TOTPState)

mkSecretFrom :: Secret m -> IO (Secret m)
mkSecretFrom sc = L.runInputT hlSettings $ do
  a <- mfilter (not . null) <$> L.getInputLineWithInitial "Account?: " (T.unpack (secAccount sc), "")
  i <-
    mfilter (not . null) <$> case secIssuer sc of
      Nothing -> L.getInputLine "Issuer? (optional): "
      Just si -> L.getInputLineWithInitial "Issuer? (optional): " (T.unpack si, "")
  let a' = case a of
        Nothing -> secAccount sc
        Just r -> T.pack r
      i' = case i of
        Nothing -> secIssuer sc
        Just r -> Just $ T.pack r
  return $
    sc
      { secAccount = a',
        secIssuer = i'
      }