packages feed

lnurl-0.1.0.0: src/LnUrl/Pay.hs

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

{- |
Module: LnUrl.Pay

See <https://github.com/fiatjaf/lnurl-rfc/blob/master/lnurl-pay.md>.

== Workflow

1. @LN WALLET@ decodes URL using 'getPayURL' and makes a @GET@ request.
2. @LN SERVICE@ responds with 'Response' 'SuccessResponse'.
3. @LN WALLET@ get parameters from the user.
4. @LN WALLET@ prepare a callback URL using 'getCallbackUrl'.
5. @LN SERVICE@ responds with 'Response' 'CallbackSuccessResponse'.
6. @LN WALLET@ verifies: (a) @h@ tag in 'paymentRequest' is @SHA256(metadata)@,
   (b) the amount in 'paymentRequest' matches the requested amount, and (c)
   signatures on @ChannelUpdate@ messages.
7. @LN WALLET@ pays invoice.
8. @LN WALLET@ after paying the invoice, execute the 'successAction' if defined.
-}
module LnUrl.Pay (
    -- * Client
    getPayURL,
    getCallbackUrl,
    decrypt,

    -- * Server
    encrypt,

    -- * Types
    Response (..),
    SuccessResponse (..),
    Metadata (..),
    CallbackSuccessResponse (..),
    Hop (..),
    SuccessAction (..),
    UrlAction (..),
    AesAction (..),
    AesError (..),
) where

import Control.Exception (Exception)
import Control.Monad (unless)
import Crypto.Cipher.AES (AES256)
import Crypto.Cipher.Types (cbcDecrypt, cbcEncrypt, makeIV)
import qualified Crypto.Cipher.Types as Crypto
import Crypto.Data.Padding (Format (PKCS7), pad, unpad)
import Crypto.Error (eitherCryptoError)
import Crypto.Random (getRandomBytes)
import Data.Aeson (
    FromJSON,
    ToJSON,
    Value (String),
    object,
    parseJSON,
    toJSON,
    withArray,
    withObject,
    (.:),
    (.:?),
    (.=),
 )
import qualified Data.Aeson as Ae
import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.ByteString.Base16 (encodeBase16')
import Data.ByteString.Base64 (decodeBase64, encodeBase64)
import qualified Data.ByteString.Char8 as BS8
import Data.Foldable (toList)
import Data.Functor ((<&>))
import Data.Maybe (catMaybes, fromMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text.Lazy.Encoding as TL
import Data.Word (Word64)
import Haskoin (PubKey, exportPubKey)
import LnUrl (NodeId, Response (..))
import LnUrl.Utils (Base64 (..), JsonURI (..), (.=?))
import Network.URI (URI (..), parseURI, uriRegName, uriUserInfo)
import Network.URI.Utils (addQueryParams, param)

-- | Apply the LNURL-pay uri transform logic.  @LN SERVICE@ should respond to the resulting URL with 'Response'.
getPayURL :: URI -> URI
getPayURL theURI
    | Just userInfo <- uriUserInfo <$> uriAuthority theURI =
        theURI
            { uriScheme = if isOnion then "http:" else "https:"
            , uriAuthority = stompUserInfo <$> uriAuthority theURI
            , uriPath = "/.well-known/lnurlp/" <> takeWhile (`notElem` specialChars) userInfo
            , uriFragment = mempty
            }
    | otherwise = theURI
  where
    isOnion = drop (length domain - 6) domain == ".onion"
    Just domain = uriRegName <$> uriAuthority theURI
    stompUserInfo uriAuth = uriAuth{uriUserInfo = mempty}
    specialChars = [':', '@']

instance FromJSON SuccessResponse where
    parseJSON = withObject "LNURL-pay Response" $ \obj -> do
        tag <- obj .: "tag"
        unless (tag == ("payRequest" :: Text)) $ fail "Must have payRequest tag"
        SuccessResponse
            <$> (getJsonURI <$> obj .: "callback")
            <*> obj .: "maxSendable"
            <*> obj .: "minSendable"
            <*> (obj .: "metadata" >>= either fail pure . Ae.eitherDecode . TL.encodeUtf8)
            <*> obj .:? "commentAllowed"

instance ToJSON SuccessResponse where
    toJSON response =
        object $
            [ "callback" .= (JsonURI . callback) response
            , "maxSendable" .= maxSendable response
            , "minSendable" .= minSendable response
            , "metadata" .= (TL.decodeUtf8 . Ae.encode . metadata) response
            ]
                <> catMaybes ["commentAllowed" .=? commentAllowed response]

data SuccessResponse = SuccessResponse
    { callback :: URI
    , -- | millisatoshi
      maxSendable :: Word64
    , -- | millisatoshi
      minSendable :: Word64
    , metadata :: [Metadata]
    , commentAllowed :: Maybe Int
    }
    deriving (Eq, Show)

{- | The metadata array

 * Must contain a 'PlainText' value
 * May contain at most one of 'ImagePNG' or 'ImageJPEG'
 * May contain at most one of 'Email' or 'Ident'
-}
data Metadata
    = PlainText Text
    | ImagePNG ByteString
    | ImageJPEG ByteString
    | Email Text
    | Ident Text
    deriving (Eq, Show)

instance FromJSON Metadata where
    parseJSON = withArray "Metadata" $ parseTuple . toList
      where
        parseTuple = \case
            [String mimeType, String val] -> case mimeType of
                "text/plain" -> pure $ PlainText val
                "text/email" -> pure $ Email val
                "text/identifier" -> pure $ Ident val
                "image/png;base64" ->
                    either (fail . Text.unpack) (pure . ImagePNG)
                        . decodeBase64
                        $ encodeUtf8 val
                "image/jpeg;base64" ->
                    either (fail . Text.unpack) (pure . ImageJPEG)
                        . decodeBase64
                        $ encodeUtf8 val
                other -> fail $ "Unknown mimetype: " <> Text.unpack other
            _ -> fail "Expected tuple"

instance ToJSON Metadata where
    toJSON =
        \case
            PlainText x -> tuple "text/plain" x
            ImagePNG x -> tuple "image/png;base64" (encodeBase64 x)
            ImageJPEG x -> tuple "image/jpeg;base64" (encodeBase64 x)
            Email x -> tuple "text/email" x
            Ident x -> tuple "text/identifier" x
      where
        tuple :: Text -> Text -> Value
        tuple = curry toJSON

-- | Prepare a callback url to use to retrieve the payment request
getCallbackUrl ::
    SuccessResponse ->
    -- | amount (millisatoshis)
    Word64 ->
    -- | cache prevention
    Maybe ByteString ->
    -- | starting points (node ids)
    [NodeId] ->
    -- | comment
    Maybe Text ->
    -- | proof of payer
    Maybe PubKey ->
    Maybe URI
getCallbackUrl response amount maybeNonce fromNodes maybeComment maybeProofOfPayer
    | commentLengthOk = Just $ addQueryParams (callback response) params
    | otherwise = Nothing
  where
    commentLengthOk = fromMaybe True $ checkComment <$> commentAllowed response <*> maybeComment
    checkComment commentSizeBound = (<= commentSizeBound) . Text.length

    params =
        catMaybes
            [ Just $ param "amount" (BS8.pack . show) amount
            , param "nonce" encodeBase16' <$> maybeNonce
            , if null fromNodes
                then mempty
                else Just $ param "fromnodes" toNodeList fromNodes
            , param "comment" encodeUtf8 <$> maybeComment
            , param "proofofpayer" (exportPubKey True) <$> maybeProofOfPayer
            ]
    toNodeList = BS.intercalate ","

data CallbackSuccessResponse = CallbackSuccessResponse
    { paymentRequest :: Text
    , successAction :: Maybe SuccessAction
    , disposable :: Bool
    , routes :: [[Hop]]
    }
    deriving (Eq, Show)

instance FromJSON CallbackSuccessResponse where
    parseJSON = withObject "CallbackSuccessResponse" $ \obj ->
        CallbackSuccessResponse
            <$> obj .: "pr"
            <*> obj .:? "successAction"
            <*> (fromMaybe True <$> obj .:? "disposable")
            <*> obj .: "routes"

instance ToJSON CallbackSuccessResponse where
    toJSON successResponse =
        object
            [ "pr" .= paymentRequest successResponse
            , "successAction" .= successAction successResponse
            , "disposable" .= disposable successResponse
            , "routes" .= routes successResponse
            ]

data SuccessAction
    = Url UrlAction
    | Message Text
    | Aes AesAction
    deriving (Eq, Show)

instance FromJSON SuccessAction where
    parseJSON = withObject "SuccessAction" $ \obj ->
        obj .: "tag" >>= \case
            "url" ->
                fmap Url $
                    UrlAction
                        <$> obj .: "description"
                        <*> (obj .: "url" >>= maybe badUrl pure . parseURI)
            "message" -> Message <$> obj .: "message"
            "aes" ->
                fmap Aes $
                    AesAction
                        <$> obj .: "description"
                        <*> (getBase64 <$> obj .: "ciphertext")
                        <*> (getBase64 <$> obj .: "iv")
            other -> fail $ "Unknown tag: " <> Text.unpack other
      where
        badUrl = fail "Unable to parse url"

instance ToJSON SuccessAction where
    toJSON = \case
        Url urlAction ->
            object
                [ "tag" .= ("url" :: Text)
                , "description" .= urlDescription urlAction
                , "url" .= show (url urlAction)
                ]
        Message msg ->
            object
                [ "tag" .= ("message" :: Text)
                , "message" .= msg
                ]
        Aes aes ->
            object
                [ "tag" .= ("aes" :: Text)
                , "description" .= aesDescription aes
                , "ciphertext" .= (Base64 . ciphertext) aes
                , "iv" .= (Base64 . iv) aes
                ]

data UrlAction = UrlAction
    { urlDescription :: Text
    , url :: URI
    }
    deriving (Eq, Show)

data AesAction = AesAction
    { aesDescription :: Text
    , ciphertext :: ByteString
    , iv :: ByteString
    }
    deriving (Eq, Show)

data Hop = Hop
    { nodeId :: Text
    , channelUpdate :: ByteString
    }
    deriving (Eq, Show)

instance FromJSON Hop where
    parseJSON = withObject "Hop" $ \obj ->
        Hop
            <$> obj .: "nodeId"
            <*> (getBase64 <$> obj .: "channelUpdate")

instance ToJSON Hop where
    toJSON theRoute =
        object
            [ "nodeId" .= nodeId theRoute
            , "channelUpdate" .= (Base64 . channelUpdate) theRoute
            ]

-- | Use the payment preimage to build an encrypted payload
encrypt ::
    -- | Payment preimage
    ByteString ->
    -- | Description
    Text ->
    -- | Message to encrypt
    ByteString ->
    IO (Either AesError AesAction)
encrypt key aesDescription plaintext = do
    iv <- getRandomBytes 16
    let Just cryptoniteIV = makeIV iv
    pure $
        cipherInit key <&> \cipher ->
            AesAction
                { aesDescription
                , iv
                , ciphertext = cbcEncrypt cipher cryptoniteIV (pad paddingConfig plaintext)
                }

-- | Use the payment preimage to get the encrypted payload
decrypt ::
    -- | Payment preimage
    ByteString ->
    AesAction ->
    Either AesError ByteString
decrypt key aes = do
    cipher <- cipherInit key
    theIV <- maybe (Left IvError) Right $ makeIV (iv aes)
    maybe (Left PaddingError) pure
        . unpad paddingConfig
        $ cbcDecrypt cipher theIV (ciphertext aes)

cipherInit :: ByteString -> Either AesError AES256
cipherInit = first (const KeyError) . eitherCryptoError . Crypto.cipherInit @AES256

paddingConfig :: Format
paddingConfig = PKCS7 16

data AesError = KeyError | IvError | PaddingError
    deriving (Eq, Show)

instance Exception AesError