packages feed

shomei-postgres-0.2.0.0: src/Shomei/Mfa/Totp/Postgres.hs

{-# LANGUAGE ScopedTypeVariables #-}

-- | PostgreSQL interpreter for the EP-7 TOTP credential store, with AES-256-GCM encryption of
-- the shared secret at the storage boundary.
--
-- The port ('Shomei.Mfa.Totp.Store') speaks in raw 'Shomei.Mfa.Totp.Algorithm.TotpSecret's;
-- encryption lives here so the workflows stay pure policy over ports and the in-memory tests
-- exercise TOTP logic rather than AES (Decision Log). Each write draws a fresh 96-bit nonce and
-- stores @nonce || ciphertext || tag@ in one @bytea@; the key comes from the server 'Env'
-- (@SHOMEI_TOTP_ENCRYPTION_KEY@), never from the database, so a dump alone yields no usable
-- secret. This follows the ChaChaPoly1305 AEAD shape in
-- @shomei-jwt/src/Shomei/Jwt/KeyProtection.hs@, adapted to AES-256-GCM.
module Shomei.Mfa.Totp.Postgres
  ( runTotpCredentialStorePostgres,
    TotpEncryptionKey,
    totpEncryptionKeyFromBytes,
    totpEncryptionKeyFromBase64,
  )
where

import Contravariant.Extras (contrazip2, contrazip4)
import Crypto.Cipher.AES (AES256)
import Crypto.Cipher.Types
  ( AEAD,
    AEADMode (AEAD_GCM),
    AuthTag (..),
    aeadInit,
    aeadSimpleDecrypt,
    aeadSimpleEncrypt,
    cipherInit,
  )
import Crypto.Error (CryptoFailable (..))
import Crypto.Random (getRandomBytes)
import Data.ByteArray qualified as BA
import Data.ByteArray.Encoding (Base (Base64), convertFromBase)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int64)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as TE
import Data.UUID (UUID)
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, throwError)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Shomei.Error (AuthError (..))
import Shomei.Id
  ( totpCredentialIdFromUUID,
    totpCredentialIdToUUID,
    userIdFromUUID,
    userIdToUUID,
  )
import Shomei.Mfa.Totp.Algorithm (TotpSecret (..))
import Shomei.Mfa.Totp.Domain (NewTotpCredential (..), TotpCredential (..))
import Shomei.Mfa.Totp.Store (TotpCredentialStore (..))
import Shomei.Persistence.Codec.Postgres (tshow)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude

-- | The AES-256-GCM key that encrypts stored TOTP secrets. Abstract: no 'Show', no JSON, so a
-- leak is a type error rather than a review question. 32 bytes, held as 'BA.ScrubbedBytes'.
newtype TotpEncryptionKey = TotpEncryptionKey BA.ScrubbedBytes

-- | Build a key from exactly 32 raw bytes.
totpEncryptionKeyFromBytes :: ByteString -> Either Text TotpEncryptionKey
totpEncryptionKeyFromBytes bs
  | BS.length bs == 32 = Right (TotpEncryptionKey (BA.convert bs))
  | otherwise = Left ("TOTP encryption key must be exactly 32 bytes, got " <> tshow (BS.length bs))

-- | Parse a key from base64 text (the value of @SHOMEI_TOTP_ENCRYPTION_KEY@); requires exactly
-- 32 decoded bytes. The 'Left' explains how to make a valid one.
totpEncryptionKeyFromBase64 :: Text -> Either Text TotpEncryptionKey
totpEncryptionKeyFromBase64 raw =
  case convertFromBase Base64 (TE.encodeUtf8 (Text.strip raw)) :: Either String ByteString of
    Left err -> Left (bad ("it is not valid base64 (" <> Text.pack err <> ")"))
    Right bs
      | BS.length bs == 32 -> Right (TotpEncryptionKey (BA.convert bs))
      | otherwise -> Left (bad ("it decodes to " <> tshow (BS.length bs) <> " bytes, not 32"))
  where
    bad reason = "is not a valid TOTP encryption key: " <> reason <> ". Generate one with: openssl rand -base64 32"

-- | The AEAD state for @(key, nonce)@ under AES-256-GCM, shared by encrypt and decrypt.
aeadState :: BA.ScrubbedBytes -> ByteString -> CryptoFailable (AEAD AES256)
aeadState key nonce = do
  cipher <- cipherInit key
  aeadInit AEAD_GCM cipher nonce

-- | Encrypt raw secret bytes: draw a 96-bit nonce, and lay out @nonce || ciphertext || tag@.
encryptSecret :: TotpEncryptionKey -> ByteString -> IO ByteString
encryptSecret (TotpEncryptionKey key) plaintext = do
  nonce <- getRandomBytes 12 :: IO ByteString
  case aeadState key nonce of
    CryptoFailed e -> ioError (userError ("shomei: cannot initialize TOTP encryption: " <> show e))
    CryptoPassed st -> do
      let (tag, ciphertext) = aeadSimpleEncrypt st (BS.empty :: ByteString) plaintext 16
      pure (nonce <> ciphertext <> BA.convert (unAuthTag tag))

-- | Recover raw secret bytes from a stored @secret_enc@ blob. A wrong key, a tampered
-- ciphertext, or a truncated blob all fail the same way (one indistinguishable error).
decryptSecret :: TotpEncryptionKey -> ByteString -> Either Text ByteString
decryptSecret (TotpEncryptionKey key) blob
  | BS.length blob < 12 + 16 = Left "TOTP secret ciphertext is shorter than nonce + tag"
  | otherwise =
      let (nonce, rest) = BS.splitAt 12 blob
          (ciphertext, tagBytes) = BS.splitAt (BS.length rest - 16) rest
       in case aeadState key nonce of
            CryptoFailed _ -> Left "TOTP secret: bad AES-GCM initialization"
            CryptoPassed st ->
              case aeadSimpleDecrypt st (BS.empty :: ByteString) ciphertext (AuthTag (BA.convert tagBytes)) of
                Just pt -> Right pt
                Nothing -> Left "TOTP secret failed authentication"

-- | The stored row, column order matching @shomei_totp_credentials@:
-- @(totp_credential_id, user_id, secret_enc, last_used_counter, confirmed_at, created_at)@.
type TotpRow = (UUID, UUID, ByteString, Maybe Int64, Maybe UTCTime, UTCTime)

runTotpCredentialStorePostgres ::
  (Database :> es, IOE :> es, Error AuthError :> es) =>
  TotpEncryptionKey ->
  Eff (TotpCredentialStore : es) a ->
  Eff es a
runTotpCredentialStorePostgres key = interpret_ \case
  UpsertTotpEnrollment NewTotpCredential {totpCredentialId, userId, secret = TotpSecret raw, createdAt} -> do
    enc <- liftIO (encryptSecret key raw)
    let params = (totpCredentialIdToUUID totpCredentialId, userIdToUUID userId, enc, createdAt)
    res <- runSession (Session.statement params upsertStmt)
    either dbFail (const (pure ())) res
    pure
      TotpCredential
        { totpCredentialId,
          userId,
          secret = TotpSecret raw,
          lastUsedCounter = Nothing,
          confirmedAt = Nothing,
          createdAt
        }
  FindTotpByUser uid -> do
    res <- runSession (Session.statement (userIdToUUID uid) findByUserStmt)
    row <- either dbFail pure res
    traverse rebuild row
  ConfirmTotp tcid t -> do
    res <- runSession (Session.statement (totpCredentialIdToUUID tcid, t) confirmStmt)
    either dbFail (const (pure ())) res
  SetTotpLastUsedCounter tcid c -> do
    res <- runSession (Session.statement (totpCredentialIdToUUID tcid, c) setCounterStmt)
    accepted <- either dbFail pure res
    pure (isJust accepted)
  DeleteTotpByUser uid -> do
    res <- runSession (Session.statement (userIdToUUID uid) deleteByUserStmt)
    either dbFail (const (pure ())) res
  where
    dbFail = throwError . postgresUnavailable
    rebuild row = either (throwError . InternalAuthError) pure (rebuildCredential key row)

rebuildCredential :: TotpEncryptionKey -> TotpRow -> Either Text TotpCredential
rebuildCredential key (tcid, uid, enc, lastUsed, confirmed, created) = do
  raw <- decryptSecret key enc
  pure
    TotpCredential
      { totpCredentialId = totpCredentialIdFromUUID tcid,
        userId = userIdFromUUID uid,
        secret = TotpSecret raw,
        lastUsedCounter = lastUsed,
        confirmedAt = confirmed,
        createdAt = created
      }

totpRowDecoder :: D.Row TotpRow
totpRowDecoder =
  (,,,,,)
    <$> D.column (D.nonNullable D.uuid)
    <*> D.column (D.nonNullable D.uuid)
    <*> D.column (D.nonNullable D.bytea)
    <*> D.column (D.nullable D.int8)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nonNullable D.timestamptz)

selectCols :: Text
selectCols = "totp_credential_id, user_id, secret_enc, last_used_counter, confirmed_at, created_at"

-- | Insert, or replace an existing (unconfirmed) enrollment for the user: on a @user_id@
-- conflict the id and secret are swapped in and the counter/confirmation are reset to NULL. The
-- workflow refuses to reach here when a /confirmed/ credential exists.
upsertStmt :: Statement (UUID, UUID, ByteString, UTCTime) ()
upsertStmt =
  preparable
    """
    INSERT INTO shomei.shomei_totp_credentials
      (totp_credential_id, user_id, secret_enc, last_used_counter, confirmed_at, created_at)
    VALUES ($1, $2, $3, NULL, NULL, $4)
    ON CONFLICT (user_id) DO UPDATE
    SET totp_credential_id = EXCLUDED.totp_credential_id,
        secret_enc = EXCLUDED.secret_enc,
        last_used_counter = NULL,
        confirmed_at = NULL,
        created_at = EXCLUDED.created_at
    """
    ( contrazip4
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.bytea))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.noResult

findByUserStmt :: Statement UUID (Maybe TotpRow)
findByUserStmt =
  preparable
    ("SELECT " <> selectCols <> " FROM shomei.shomei_totp_credentials WHERE user_id = $1")
    (E.param (E.nonNullable E.uuid))
    (D.rowMaybe totpRowDecoder)

confirmStmt :: Statement (UUID, UTCTime) ()
confirmStmt =
  preparable
    """
    UPDATE shomei.shomei_totp_credentials
    SET confirmed_at = $2
    WHERE totp_credential_id = $1
    """
    (contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
    D.noResult

setCounterStmt :: Statement (UUID, Int64) (Maybe UUID)
setCounterStmt =
  preparable
    """
    UPDATE shomei.shomei_totp_credentials
    SET last_used_counter = $2
    WHERE totp_credential_id = $1
      AND (last_used_counter IS NULL OR last_used_counter < $2)
    RETURNING totp_credential_id
    """
    (contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.int8)))
    (D.rowMaybe (D.column (D.nonNullable D.uuid)))

deleteByUserStmt :: Statement UUID ()
deleteByUserStmt =
  preparable
    "DELETE FROM shomei.shomei_totp_credentials WHERE user_id = $1"
    (E.param (E.nonNullable E.uuid))
    D.noResult