packages feed

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

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

module LnUrl.Authenticator.Storage (
    -- * File format
    LogEntry (..),
    AuthIdentity (..),
    addLogEntry,
    getLog,

    -- * Generic encrypted storage
    encryptJsonFile,
    decryptJsonFile,
) where

import Control.Monad ((<=<))
import Crypto.Cipher.AES (AES256)
import Crypto.Cipher.Types (cbcDecrypt, cbcEncrypt, cipherInit, makeIV)
import Crypto.Data.Padding (Format (PKCS7), pad, unpad)
import Crypto.Error (throwCryptoErrorIO)
import Crypto.KDF.Scrypt (Parameters (Parameters), generate)
import Crypto.Random (getRandomBytes)
import Data.Aeson (
    FromJSON,
    ToJSON,
    decode,
    encode,
    object,
    parseJSON,
    toJSON,
    withObject,
    (.:),
    (.=),
 )
import Data.Aeson.Types (Parser)
import Data.ByteArray (ScrubbedBytes, convert)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BSL
import Data.Map.Lazy (Map, findWithDefault)
import qualified Data.Map.Lazy as Map
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time (UTCTime, getCurrentTime)
import Data.Word (Word32)
import Haskoin (XPrvKey, btc, xPrvFromJSON, xPrvToJSON)
import LnUrl.Auth (Action (..), actionText)
import qualified LnUrl.Auth as Auth
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory)

{- | Each LNURL-auth interaction generates a log entry recording the action and
 the timestamp.
-}
data LogEntry = LogEntry
    { action :: Maybe Action
    , timestamp :: UTCTime
    }
    deriving (Eq, Show)

instance FromJSON LogEntry where
    parseJSON = withObject "LogEntry" $ \obj ->
        LogEntry
            <$> (obj .: "action" >>= parseAction)
            <*> obj .: "timestamp"
      where
        parseAction = maybe (pure Nothing) $ maybe noParse (pure . Just) . Auth.parseAction
        noParse = fail "Unknown action"

instance ToJSON LogEntry where
    toJSON logEntry =
        object
            [ "action" .= (fmap actionText . action) logEntry
            , "timestamp" .= timestamp logEntry
            ]

type Version = Word32

-- | An identity is a private key together with an activity log
data AuthIdentity = AuthIdentity
    { xprv :: XPrvKey
    , activityLog :: Map Text [LogEntry]
    , version :: Version
    }
    deriving (Eq, Show)

instance FromJSON AuthIdentity where
    parseJSON = withObject "AuthIdentity" $ \obj ->
        AuthIdentity
            <$> (obj .: "xprv" >>= xPrvFromJSON btc)
            <*> obj .: "log"
            <*> (obj .: "version" >>= versionGuard)
      where
        versionGuard :: Word32 -> Parser Word32
        versionGuard = \case
            1 -> pure 1
            _ -> fail "Unknown version"

instance ToJSON AuthIdentity where
    toJSON AuthIdentity{xprv, activityLog, version} =
        object
            [ "xprv" .= xPrvToJSON btc xprv
            , "log" .= activityLog
            , "version" .= version
            ]

-- | Add an entry to the activity log for a domain
addLogEntry :: String -> Maybe Action -> AuthIdentity -> IO AuthIdentity
addLogEntry domain possibleAction a@AuthIdentity{activityLog} = do
    now <- getCurrentTime
    let logEntry = LogEntry possibleAction now
    pure a{activityLog = Map.insertWith (<>) (Text.pack domain) [logEntry] activityLog}

-- | Retrieve the activity log for a particular domain
getLog :: String -> AuthIdentity -> [LogEntry]
getLog domain AuthIdentity{activityLog} =
    findWithDefault mempty (Text.pack domain) activityLog

{- | Encrypt a JSON blob to a file, formatted like: @<SALT><IV><CIPHERTEXT>@ where

 * @SALT@ is 32 bytes used for key derivation
 * @IV@ is the 16 byte AES-256 iv
 * @CIPHERTEXT@ is the encrypted plaintext, padded with 16-byte PKCS7
-}
encryptJsonFile ::
    ToJSON a =>
    -- | User key
    ScrubbedBytes ->
    -- | Path to ident file
    FilePath ->
    -- | Data to encrypt
    a ->
    IO ()
encryptJsonFile userKey path value = do
    createDirectoryIfMissing True (takeDirectory path)
    salt <- getRandomBytes 32
    let key = stretchKey salt userKey
    aes <- throwCryptoErrorIO $ cipherInit @AES256 key
    Just iv <- makeIV <$> getRandomBytes @_ @ByteString 16
    BS.writeFile path $
        salt
            <> convert iv
            <> ( cbcEncrypt aes iv . pad paddingScheme
                    . BSL.toStrict
                    . encode
               )
                value

-- | Extract a JSON payload from a file.  See 'encryptJsonFile' for the layout.
decryptJsonFile ::
    FromJSON a =>
    -- | User key
    ScrubbedBytes ->
    FilePath ->
    IO (Maybe a)
decryptJsonFile userKey path = do
    content <- BS.readFile path

    let salt = BS.take 32 content
        Just iv = (makeIV . BS.take 16 . BS.drop 32) content
        ciphertext = BS.drop 48 content
        key = stretchKey salt userKey

    if BS.length content < 48
        then pure Nothing
        else do
            aes <- throwCryptoErrorIO $ cipherInit @AES256 key
            pure . (decode . BSL.fromStrict <=< unpad paddingScheme) $
                cbcDecrypt aes iv ciphertext

{- | Stretch a key using scrypt
 <https://blog.filippo.io/the-scrypt-parameters/>
-}
stretchKey ::
    -- | Salt
    ByteString ->
    -- | Pasword
    ScrubbedBytes ->
    ByteString
stretchKey = generate params
  where
    params = Parameters (2 ^ (20 :: Int)) 8 1 32

paddingScheme :: Format
paddingScheme = PKCS7 16