hprox-0.7.0: src/Network/HProx/Util.hs
-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.
module Network.HProx.Util
( Password(..)
, PasswordHashError(..)
, PasswordSalted(..)
, hashPasswordWithRandomSalt
, hashPasswordWithSalt
, parseHostPort
, parseHostPortWithDefault
, passwordReader
, passwordWriter
, responseKnownLength
, splitBy
, verifyPassword
) where
import Control.Exception (Exception, throwIO)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as LBS
import Data.List.NonEmpty (NonEmpty(..), (<|))
import Data.List.NonEmpty qualified as NE
import Data.Maybe (fromMaybe)
import Network.HTTP.Types (ResponseHeaders, Status)
import Network.Wai
import Crypto.Error (CryptoFailable(..))
import Crypto.KDF.Argon2 qualified as Argon2
import Crypto.Random (MonadRandom(getRandomBytes))
import Data.ByteString.Base64 qualified as Base64
data Password = PlainText !BS.ByteString
| Salted !BS.ByteString !BS.ByteString
deriving (Show, Eq)
data PasswordHashError = PasswordHashError !String
deriving (Show, Eq)
instance Exception PasswordHashError
data PasswordSalted = PasswordSalted !BS.ByteString !BS.ByteString
deriving (Show, Eq)
splitBy :: Eq a => a -> [a] -> NonEmpty [a]
splitBy _ [] = NE.singleton []
splitBy c (x:xs)
| c == x = [] <| splitBy c xs
| otherwise = let y :| ys = splitBy c xs in (x:y) :| ys
passwordReader :: BS.ByteString -> Maybe (BS.ByteString, Password)
passwordReader line = case BS8.split ':' line of
[user, pass] -> Just (user, PlainText pass)
[user, salt, hashed] -> case (Base64.decode salt, Base64.decode hashed) of
(Right salt', Right hashed') -> Just (user, Salted salt' hashed')
_otherwise -> Nothing
_otherwise -> Nothing
passwordWriter :: BS.ByteString -> PasswordSalted -> BS.ByteString
passwordWriter user (PasswordSalted salt hash) =
BS.concat [user, ":", Base64.encode salt, ":", Base64.encode hash]
hashPasswordWithSalt :: BS.ByteString -> Password -> Either PasswordHashError PasswordSalted
hashPasswordWithSalt salt (PlainText pass) =
case Argon2.hash Argon2.defaultOptions pass salt 48 of
CryptoFailed err -> Left (PasswordHashError $ "unable to hash password with salt: " ++ show err)
CryptoPassed h -> Right (PasswordSalted salt h)
hashPasswordWithSalt _ (Salted salt h) = Right (PasswordSalted salt h)
hashPasswordWithRandomSalt :: Password -> IO PasswordSalted
hashPasswordWithRandomSalt password@(PlainText _) = do
salt <- getRandomBytes 24
either throwIO return (hashPasswordWithSalt salt password)
hashPasswordWithRandomSalt password@(Salted _ _) =
either throwIO return (hashPasswordWithSalt "" password)
verifyPassword :: PasswordSalted -> BS8.ByteString -> Bool
verifyPassword (PasswordSalted salt hashed) pass =
case Argon2.hash Argon2.defaultOptions pass salt 48 of
CryptoFailed _ -> False
CryptoPassed h -> h == hashed
parseHostPort :: BS.ByteString -> Maybe (BS.ByteString, Int)
parseHostPort hostPort = do
lastColon <- BS8.elemIndexEnd ':' hostPort
port <- BS8.readInt (BS.drop (lastColon + 1) hostPort) >>= checkPort
return (BS.take lastColon hostPort, port)
where
checkPort (p, bs)
| BS.null bs && 1 <= p && p <= 65535 = Just p
| otherwise = Nothing
parseHostPortWithDefault :: Int -> BS.ByteString -> (BS.ByteString, Int)
parseHostPortWithDefault defaultPort hostPort =
fromMaybe (hostPort, defaultPort) $ parseHostPort hostPort
responseKnownLength :: Status -> ResponseHeaders -> LBS.ByteString -> Response
responseKnownLength status headers bs =
responseLBS status (headers ++ [("Content-Length", BS8.pack $ show (LBS.length bs))]) bs