shomei-server-0.3.0.0: src/Shomei/Server/BreachChecker.hs
{-# LANGUAGE DataKinds #-}
-- | EP-3 production interpreter for 'PasswordBreachChecker': a HIBP Pwned Passwords range
-- query using k-anonymity. Only the 5-char SHA-1 prefix leaves the process; the full hash and the
-- plaintext never go on the wire. Constructed once when the effect stack is assembled, from a
-- shared TLS 'Manager' and a fixed per-call timeout (the EP-1 @breachCheckTimeoutMs@ policy).
--
-- Any transport error or timeout is caught and reported as 'BreachCheckUnavailable', leaving the
-- fail-open / fail-closed decision to 'Shomei.Account.Password.Breach.Workflow.enforceBreachPolicy'.
module Shomei.Server.BreachChecker (runPasswordBreachCheckerHibp, runPasswordBreachCheckerWith) where
import Control.Exception (SomeAsyncException, SomeException, fromException, throwIO, try)
import Data.ByteString.Lazy qualified as LBS
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Network.HTTP.Client
( Manager,
Request (..),
httpLbs,
parseRequest,
responseBody,
responseTimeoutMicro,
)
import Network.HTTP.Types.Header (hUserAgent)
import Shomei.Account.Password.Breach.Store
( BreachResult (..),
PasswordBreachChecker (..),
parseHibpResponse,
sha1PrefixSuffix,
)
import Shomei.Notify (classifyWebhookFailure, reasonText)
import System.IO (hPutStrLn, stderr)
-- | Build the interpreter from a shared TLS manager and a timeout in milliseconds (pass
-- @cfg.passwordPolicy.breachCheckTimeoutMs@ at assembly time).
runPasswordBreachCheckerHibp ::
(IOE :> es) => Manager -> Int -> Eff (PasswordBreachChecker : es) a -> Eff es a
runPasswordBreachCheckerHibp mgr timeoutMs = runPasswordBreachCheckerWith (queryRange mgr timeoutMs) (hPutStrLn stderr . Text.unpack)
-- | Injectable dependency/report boundary. Only the k-anonymity prefix reaches the query;
-- reports carry a closed reason code and never contain the prefix or exception text.
runPasswordBreachCheckerWith ::
(IOE :> es) => (Text -> IO Text) -> (Text -> IO ()) -> Eff (PasswordBreachChecker : es) a -> Eff es a
runPasswordBreachCheckerWith query report = interpret_ \case
CheckPasswordBreached plain -> liftIO do
let (prefix, suffix) = sha1PrefixSuffix plain
result <- try (query prefix) :: IO (Either SomeException Text)
case result of
Left err | Just _ <- (fromException err :: Maybe SomeAsyncException) -> throwIO err
Left err -> do
report ("[shomei:breach-check] unavailable reason=" <> reasonText (classifyWebhookFailure err))
pure BreachCheckUnavailable
Right body -> pure (if parseHibpResponse body suffix then Breached else NotBreached)
-- | Fetch the HIBP range bucket for a 5-char prefix as decoded text. Throws on transport
-- errors and timeouts (caught by the interpreter above).
queryRange :: Manager -> Int -> Text -> IO Text
queryRange mgr timeoutMs prefix = do
base <- parseRequest ("https://api.pwnedpasswords.com/range/" <> Text.unpack prefix)
let req =
base
{ requestHeaders =
("Add-Padding", "true")
: (hUserAgent, "shomei")
: requestHeaders base,
responseTimeout = responseTimeoutMicro (timeoutMs * 1000)
}
resp <- httpLbs req mgr
pure (TE.decodeUtf8 (LBS.toStrict (responseBody resp)))