packages feed

lnurl-authenticator-0.1.0.0: src/LnUrl/Authenticator.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
{-# OPTIONS_GHC -Wno-type-defaults #-}

module LnUrl.Authenticator (
    handleInit,
    handleAuth,
    handleLog,
    handleList,
    AuthenticatorError (..),
) where

import Codec.Binary.Bech32 (DecodingError)
import qualified Codec.Binary.Bech32 as Bech32
import Control.Exception (Exception, throwIO)
import Control.Monad (when)
import Control.Monad.IO.Class (liftIO)
import Crypto.Random (getRandomBytes)
import qualified Data.Aeson as Ae
import Data.Bifunctor (bimap)
import Data.ByteArray (ScrubbedBytes, convert)
import Data.Char (toLower)
import qualified Data.Map as Map
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Time (defaultTimeLocale, formatTime)
import Haskoin (makeXPrvKey)
import LnUrl.Auth (
    AckResponse (..),
    Action (Register),
    AuthUrl,
    action,
    actionText,
    authDomain,
    getSignedCallback,
    parseAuthUrl,
 )
import LnUrl.Authenticator.Storage (
    AuthIdentity (..),
    LogEntry,
    addLogEntry,
    decryptJsonFile,
    encryptJsonFile,
    getLog,
 )
import qualified LnUrl.Authenticator.Storage as S
import Network.HTTP.Client (httpLbs, parseRequest, responseBody)
import Network.HTTP.Client.TLS (newTlsManager)
import System.Console.Haskeline (
    InputT,
    defaultSettings,
    getInputChar,
    outputStrLn,
    runInputT,
 )
import qualified System.Console.Haskeline as HL

-- | Create a new LNURL-auth vault
handleInit :: FilePath -> IO ()
handleInit identFile = do
    putStrLn $ "Creating a new identity file: " <> identFile
    xprv <- makeXPrvKey <$> getRandomBytes 32
    password <- passwordInit
    encryptJsonFile password identFile $ AuthIdentity{xprv, activityLog = mempty, version = 1}
    putStrLn "Success!"

passwordInit :: IO ScrubbedBytes
passwordInit = runInputT defaultSettings $ do
    mpassword <- HL.getPassword Nothing "Encryption password: "
    mconfirmation <- HL.getPassword Nothing "Confirm encryption password: "
    case (mpassword, mconfirmation) of
        (Just password, Just confirmation)
            | password == confirmation -> (pure . convert . encodeUtf8 . Text.pack) password
        _ -> liftIO . throwIO $ PasswordEntryError

getPassword :: InputT IO ScrubbedBytes
getPassword =
    maybe
        mempty
        ( convert
            . encodeUtf8
            . Text.pack
        )
        <$> HL.getPassword Nothing "Encryption password: "

-- | Run LNURL-auth against a bech32-encoded URL
handleAuth :: FilePath -> String -> IO ()
handleAuth identFile bech32Url = do
    putStrLn "Starting LNURL-auth flow"
    either throwIO onUrl $
        decodeBech32 (Text.pack bech32Url)
            >>= \case
                ("lnurl", Just bytes) -> pure . Text.unpack $ decodeUtf8 bytes
                _ -> Left Bech32Error
  where
    decodeBech32 =
        bimap
            Bech32DecodingError
            (bimap Bech32.humanReadablePartToText Bech32.dataPartToBytes)
            . Bech32.decodeLenient

    onUrl theURL = runInputT defaultSettings $ do
        password <- getPassword
        authIdent <- liftIO $ getIdentity password identFile
        authUrl <- liftIO $ maybe noParse pure $ parseAuthUrl theURL

        let domain = authDomain authUrl
            domainLog = getLog domain authIdent
            possibleAction = action authUrl

        response <- promptUser domain domainLog possibleAction
        when response . liftIO $
            handleCallback authIdent authUrl
                >>= \case
                    AckSuccess -> do
                        addLogEntry domain possibleAction authIdent
                            >>= encryptJsonFile password identFile
                        putStrLn "Success!"
                    AckError reason -> throwIO $ ResponseError reason

    noParse = throwIO AuthUrlParseError

getIdentity :: ScrubbedBytes -> FilePath -> IO AuthIdentity
getIdentity password identFile =
    maybe noIdent pure =<< decryptJsonFile @AuthIdentity password identFile
  where
    noIdent = throwIO IdentityFileError

handleCallback ::
    AuthIdentity ->
    AuthUrl ->
    IO AckResponse
handleCallback authIdent authUrl = do
    mgr <- newTlsManager
    parseRequest theCallback
        >>= fmap (Ae.decode . responseBody) . flip httpLbs mgr
        >>= maybe onResponseDecodingError pure
  where
    onResponseDecodingError = throwIO ResponseDecodingError
    theCallback = show $ getSignedCallback (xprv authIdent) authUrl

promptUser :: String -> [LogEntry] -> Maybe Action -> InputT IO Bool
promptUser domain domainLog possibleAction = do
    outputStrLn $ maybe noAction onAction possibleAction
    when (alreadyRegistered possibleAction) $
        outputStrLn "This domain has already registered."
    userAccepts <$> getInputChar "Proceed (y/n)? "
  where
    noAction = domain <> " did not supply an action."
    onAction theAction = domain <> " requests " <> Text.unpack (actionText theAction)

    alreadyRegistered = \case
        Just Register -> Just Register `elem` (S.action <$> domainLog)
        _ -> False

    userAccepts = (== Just 'y') . fmap toLower

handleLog :: FilePath -> String -> IO ()
handleLog identFile domain = runInputT defaultSettings $ do
    password <- getPassword
    authIdent <- liftIO $ getIdentity password identFile
    mapM_ outputLog $ getLog domain authIdent
  where
    outputLog logEntry = outputStrLn $ "* " <> timeToString logEntry <> " - " <> activityString logEntry
    timeToString = formatTime defaultTimeLocale "%F %T" . S.timestamp
    activityString = maybe "No action" (Text.unpack . actionText) . S.action

handleList :: FilePath -> IO ()
handleList identFile = runInputT defaultSettings $ do
    password <- getPassword
    theLog <- liftIO $ activityLog <$> getIdentity password identFile
    when (null theLog) $ outputStrLn "No domain registrations"
    mapM_ (outputStrLn . Text.unpack) $ Map.keys theLog

data AuthenticatorError
    = NoUrl
    | PasswordEntryError
    | AuthUrlParseError
    | IdentityFileError
    | Bech32Error
    | Bech32DecodingError DecodingError
    | ResponseDecodingError
    | ResponseError Text
    deriving (Eq, Show)

instance Exception AuthenticatorError