packages feed

pg-wire-0.1.0.0: src/PgWire/Auth/ScramSHA256.hs

-- | SCRAM-SHA-256 authentication (RFC 5802 / RFC 7677), with optional
-- @tls-server-end-point@ channel binding.
--
-- __Audience:__ exposed for downstream library authors. The standard
-- authentication flow is driven from "PgWire.Connection"; this module
-- is only useful if you are reimplementing handshake.
module PgWire.Auth.ScramSHA256
  ( scramAuth
  , scramAuthWithChannelBinding
  ) where

import Control.Monad (unless)
import Text.Read (readMaybe)
import Crypto.Hash (SHA256 (..), hashWith)
import Crypto.KDF.PBKDF2 qualified as PBKDF2
import Crypto.MAC.HMAC (HMAC, hmac)
import Crypto.Random (getRandomBytes)
import Data.ByteArray qualified as BA
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Base64 qualified as B64
import Data.ByteString.Char8 qualified as BS8
import Data.Bits (xor)
import PgWire.Error (PgWireError (..), throwPgWire)
import PgWire.Protocol.Backend (AuthType (..), BackendMsg (..), PgError (..))
import PgWire.Protocol.Frontend (FrontendMsg (..))
import PgWire.Wire (WireConn, recvBackendMsg, sendFrontendMsg)

-- | Perform SCRAM-SHA-256 authentication without channel binding.
scramAuth :: WireConn -> ByteString -> ByteString -> IO ()
scramAuth wc user password = scramAuthInternal wc user password Nothing

-- | Perform SCRAM-SHA-256-PLUS authentication with tls-server-end-point
-- channel binding. The ByteString argument is the SHA-256 hash of the
-- server's TLS certificate (DER encoded).
scramAuthWithChannelBinding :: WireConn -> ByteString -> ByteString -> ByteString -> IO ()
scramAuthWithChannelBinding wc user password certHash =
  scramAuthInternal wc user password (Just certHash)

scramAuthInternal :: WireConn -> ByteString -> ByteString -> Maybe ByteString -> IO ()
scramAuthInternal wc user password mCertHash = do
  nonceBytes <- getRandomBytes 18 :: IO ByteString
  let clientNonce = B64.encode nonceBytes

      -- Channel binding header:
      --   "n,," for no channel binding
      --   "p=tls-server-end-point,," for tls-server-end-point
      (gs2Header, cbData, mechName) = case mCertHash of
        Nothing ->
          ("n,,", "n,,", "SCRAM-SHA-256")
        Just certHash ->
          let hdr = "p=tls-server-end-point,,"
           in (hdr, hdr <> certHash, "SCRAM-SHA-256-PLUS")

      clientFirstBare = "n=" <> user <> ",r=" <> clientNonce
      clientFirstMsg = gs2Header <> clientFirstBare

  -- Send SASL initial response
  sendFrontendMsg wc (SASLInitialResponse mechName clientFirstMsg)

  -- Receive server first message
  msg1 <- recvBackendMsg wc
  serverFirstMsg <- case msg1 of
    Authentication (AuthSASLContinue serverData) -> pure serverData
    Authentication AuthOk -> pure ""
    ErrorResponse err -> throwPgWire (AuthError (pgMessage err))
    other -> throwPgWire (AuthError ("Unexpected message during SCRAM: " <> BS8.pack (show other)))

  -- Parse server first message: r=<nonce>,s=<salt>,i=<iterations>
  let serverFields = parseScramFields serverFirstMsg
  serverNonce <- lookupField "r" serverFields
  saltB64 <- lookupField "s" serverFields
  iterStr <- lookupField "i" serverFields

  iterations <- case readMaybe (BS8.unpack iterStr) :: Maybe Int of
    Just n | n > 0 -> pure n
    _ -> throwPgWire (AuthError ("Bad SCRAM iteration count: " <> iterStr))
  salt <- case B64.decode saltB64 of
    Left err -> throwPgWire (AuthError ("Bad salt base64: " <> BS8.pack err))
    Right s -> pure s

  -- Verify server nonce starts with our client nonce
  unless (clientNonce `BS.isPrefixOf` serverNonce) $
    throwPgWire (AuthError "Server nonce doesn't start with client nonce")

  -- Compute proofs
  let saltedPassword = hi password salt iterations
      clientKey = hmacSHA256 saltedPassword "Client Key"
      storedKey = hashSHA256 clientKey
      serverKey = hmacSHA256 saltedPassword "Server Key"

      channelBinding = B64.encode cbData
      clientFinalWithoutProof = "c=" <> channelBinding <> ",r=" <> serverNonce
      authMessage = clientFirstBare <> "," <> serverFirstMsg <> "," <> clientFinalWithoutProof

      clientSignature = hmacSHA256 storedKey authMessage
      clientProof = BS.pack (BS.zipWith xor clientKey clientSignature)
      serverSignature = hmacSHA256 serverKey authMessage

      clientFinalMsg = clientFinalWithoutProof <> ",p=" <> B64.encode clientProof

  -- Send client final message
  sendFrontendMsg wc (SASLResponse clientFinalMsg)

  -- Receive server final message
  msg2 <- recvBackendMsg wc
  case msg2 of
    Authentication (AuthSASLFinal serverData) -> do
      let sFields = parseScramFields serverData
      case lookup "v" sFields of
        Nothing -> throwPgWire (AuthError "Missing server signature in SASL final")
        Just sigB64 -> case B64.decode sigB64 of
          Left err -> throwPgWire (AuthError ("Bad server sig base64: " <> BS8.pack err))
          Right sig
            | not (BA.constEq sig serverSignature) ->
                throwPgWire (AuthError "Server signature mismatch")
            | otherwise -> pure ()
    Authentication AuthOk -> pure ()
    ErrorResponse err -> throwPgWire (AuthError (pgMessage err))
    other -> throwPgWire (AuthError ("Unexpected message during SCRAM final: " <> BS8.pack (show other)))

  -- Wait for AuthOk
  msg3 <- recvBackendMsg wc
  case msg3 of
    Authentication AuthOk -> pure ()
    ErrorResponse err -> throwPgWire (AuthError (pgMessage err))
    _ -> throwPgWire (AuthError "Expected AuthOk after SCRAM")

-- Crypto helpers ----------------------------------------------------------

hmacSHA256 :: ByteString -> ByteString -> ByteString
hmacSHA256 key msg = BA.convert (hmac key msg :: HMAC SHA256)
{-# INLINE hmacSHA256 #-}

hashSHA256 :: ByteString -> ByteString
hashSHA256 bs = BA.convert (hashWith SHA256 bs)
{-# INLINE hashSHA256 #-}

-- | PBKDF2-SHA256 (called "Hi" in SCRAM spec).
hi :: ByteString -> ByteString -> Int -> ByteString
hi password salt iterations =
  PBKDF2.generate
    (PBKDF2.prfHMAC SHA256)
    (PBKDF2.Parameters iterations 32)
    password
    salt

-- SCRAM field parsing -----------------------------------------------------

parseScramFields :: ByteString -> [(ByteString, ByteString)]
parseScramFields bs =
  [ (key, val)
  | field <- BS8.split ',' bs
  , let (key, rest) = BS.break (== 61) field -- '='
  , not (BS.null rest)
  , let val = BS.drop 1 rest
  ]

lookupField :: ByteString -> [(ByteString, ByteString)] -> IO ByteString
lookupField key fields =
  case lookup key fields of
    Nothing -> throwPgWire (AuthError ("Missing SCRAM field: " <> key))
    Just v -> pure v