packages feed

lnurl-0.1.0.0: src/LnUrl/Auth.hs

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

{- |
 Module: LnUrl.Auth

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

 == Workflow

 1. @LN SERVICE@ Use 'mkAuthUrl' to generate.
 2. @LN WALLET@ Decode @LNURL@ and parse with 'parseAuthUrl'.
 3. @LN WALLET@ Display 'AuthUrl' details and get consent from user to authorize.
 4. @LN WALLET@ Use 'getSignedCallback' to prepare follow-up @GET@ request
 5. @LN SERVICE@ Responds with 'AckResponse'.
-}
module LnUrl.Auth (
    -- * Server
    mkAuthUrl,

    -- * Client
    parseAuthUrl,
    authDomain,
    getSignedCallback,

    -- * Types
    AuthUrl (..),
    Action (..),
    actionText,
    parseAction,
    AckResponse (..),

    -- * Utilities
    deriveLinkingKey,
    deriveLinkingPubKey,
) where

import Control.Monad (join, replicateM, (>=>))
import Crypto.Hash (SHA256)
import Crypto.MAC.HMAC (HMAC, hmac)
import Data.ByteArray (convert)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.ByteString.Base16 (decodeBase16, encodeBase16')
import Data.Either.Extra (eitherToMaybe)
import Data.Serialize (getWord32be, runGet)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Haskoin (
    DerivPath,
    DerivPathI (..),
    Msg,
    PubKey,
    SecKey,
    XPrvKey,
    derivePath,
    derivePubKey,
    exportPubKey,
    exportSig,
    getMsg,
    getSecKey,
    listToPath,
    msg,
    signMsg,
    xPrvKey,
    (++/),
 )
import LnUrl (AckResponse (..))
import Network.HTTP.Types (parseQuery)
import Network.URI (URI, parseURI, uriAuthority, uriQuery, uriRegName)
import Network.URI.Utils (addQueryParams)

-- | Add the challenge and action parameters to a URI
mkAuthUrl :: URI -> Msg -> Maybe Action -> URI
mkAuthUrl origURI challenge maybeAction =
    addQueryParams
        origURI
        $ ("k1", Just hexChallenge) : maybe mempty (pure . mkActionParam) maybeAction
  where
    hexChallenge = encodeBase16' $ getMsg challenge
    mkActionParam theAction =
        ( "action"
        , (Just . encodeUtf8 . actionText) theAction
        )

data Action
    = Register
    | Login
    | Link
    | Auth
    deriving (Eq, Show)

actionText :: Action -> Text
actionText = \case
    Register -> "register"
    Login -> "login"
    Link -> "link"
    Auth -> "auth"

data AuthUrl = AuthUrl
    { uri :: URI
    , k1 :: Msg
    , action :: Maybe Action
    }
    deriving (Eq, Show)

authDomain :: AuthUrl -> String
authDomain = maybe mempty uriRegName . uriAuthority . uri

-- | Attempt to interpret a URL as an LNURL-auth URL
parseAuthUrl :: String -> Maybe AuthUrl
parseAuthUrl = parseURI >=> onURI
  where
    onURI uri = do
        let q = parseQuery . encodeUtf8 . Text.pack $ uriQuery uri
        k1 <- (join . lookup "k1") q >>= eitherToMaybe . decodeBase16 >>= msg
        action <-
            maybe
                (pure Nothing)
                (fmap pure . parseAction . decodeUtf8)
                $ (join . lookup "action") q
        pure AuthUrl{uri, k1, action}

parseAction :: Text -> Maybe Action
parseAction = \case
    "register" -> pure Register
    "login" -> pure Login
    "link" -> pure Link
    "auth" -> pure Auth
    _ -> Nothing

-- LNURL-auth uses a fixed path for the hashing key
hashingPath :: DerivPath
hashingPath = Deriv :| 138 :/ 0

-- Key used to calculate the key path for a domain
hashingKey :: XPrvKey -> SecKey
hashingKey = xPrvKey . derivePath hashingPath

-- | Derive the linking key from the domain
deriveLinkingKey ::
    XPrvKey ->
    -- | domain
    ByteString ->
    SecKey
deriveLinkingKey prv domain = xPrvKey $ derivePath linkingPath prv
  where
    linkingPath, linkingPathPrefix :: DerivPath
    linkingPathPrefix = Deriv :| 138
    Right linkingPath =
        fmap ((linkingPathPrefix ++/) . listToPath . take 4)
            . runGet (replicateM 4 getWord32be)
            . BS.take 16
            . convert @(HMAC SHA256)
            $ (hmac . getSecKey) (hashingKey prv) domain

-- | Derive a public signing key
deriveLinkingPubKey ::
    XPrvKey ->
    -- | domain
    ByteString ->
    PubKey
deriveLinkingPubKey prv = derivePubKey . deriveLinkingKey prv

-- | Use the linking key to sign a challenge
getSignedCallback ::
    -- | Root key
    XPrvKey ->
    AuthUrl ->
    -- | Callback url with client-provided LNURL-auth paramaters
    URI
getSignedCallback prv authUrl =
    addQueryParams
        (uri authUrl)
        [ ("sig", Just (encodeBase16' sig))
        , ("key", Just (encodeBase16' linkingKey))
        ]
  where
    Just domain = getDomain authUrl
    linkingPrvKey = deriveLinkingKey prv domain
    linkingKey = (exportPubKey True . derivePubKey) linkingPrvKey
    sig = (exportSig . signMsg linkingPrvKey . k1) authUrl

getDomain :: AuthUrl -> Maybe ByteString
getDomain = fmap (encodeUtf8 . Text.pack . uriRegName) . uriAuthority . uri