packages feed

shomei-server-0.3.0.0: app/Shomei/Admin/Keys.hs

-- | The signing-key lifecycle for @shomei-admin@ (EP-4, M2): generate → activate (old key
-- auto-retires) → retire → revoke, plus the two read helpers @keys list@ and the
-- publishable-key read used to build a JWKS during rotation.
--
-- Key state is read and written with binary-local @hasql@ statements rather than the
-- 'Shomei.SigningKey.Store' effect, because (a) the effect's @ListActiveSigningKeys@ only
-- returns @active@ keys, but rotation needs to publish @active@ AND @retired@ keys, and (b) a CLI
-- benefits from one consistent, explicit SQL style with no effect-stack assembly. We deliberately
-- do NOT reuse @shomei-jwt@'s MasterPlan-1 @rotateSigningKey@ (which inserts new keys directly as
-- @active@); the CLI drives the fuller @pending → active → retired → revoked@ lifecycle, reusing
-- only key generation and the 'StoredSigningKey' conversion from @shomei-jwt@.
module Shomei.Admin.Keys
  ( keysGenerate,
    keysActivate,
    keysRetire,
    keysRevoke,
    keysList,
    keysRewrap,
    listPublishableSigningKeys,
    listAllKeys,
    summarizeUsageError,
  )
where

import Contravariant.Extras (contrazip2, contrazip9)
import Control.Monad (forM, forM_, unless)
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Time (UTCTime, getCurrentTime)
import Effectful (runEff)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Errors qualified as Hasql
import Hasql.Session (Session)
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Shomei.Persistence.Codec.Postgres (signingKeyStatusFromText, signingKeyStatusToText)
import Shomei.Persistence.Database.Postgres (runDatabasePool, runTransaction)
import Shomei.Persistence.Pool.Postgres (Pool)
import Shomei.Persistence.Pool.Postgres qualified as Pool
import Shomei.Persistence.Transaction.Postgres (Transaction)
import Shomei.Persistence.Transaction.Postgres qualified as Tx
import Shomei.Server.DatabaseError (summarizeUsageError)
import Shomei.SigningKey.Domain (SigningAlgorithm, SigningKeyStatus (..), StoredSigningKey (..), signingAlgorithmToText)
import Shomei.SigningKey.Key.Jwt (generateSigningKeyFor, toStoredSigningKeyFor)
import Shomei.SigningKey.Protection.Jwt
  ( KeyEncryptionKey,
    decryptPrivateJwk,
    encryptPrivateJwk,
    protectStoredSigningKey,
  )
import System.Exit (exitFailure)
import System.IO (hPutStrLn, stderr)

-- Public actions -------------------------------------------------------------

-- | Mint a new key for @alg@ in @pending@ status and print its @kid@. When a
-- key-encryption key is configured, the private material is encrypted before it is written,
-- so a rotation never introduces a plaintext row into an encrypted table.
keysGenerate :: KeyEncryptionKey -> SigningAlgorithm -> Pool -> IO ()
keysGenerate kek alg pool = do
  now <- getCurrentTime
  jwk <- generateSigningKeyFor alg
  stored <- either (die . Text.unpack) pure (toStoredSigningKeyFor alg now jwk)
  pending <-
    protectStoredSigningKey
      kek
      stored
        { status = KeyPending,
          activatedAt = Nothing,
          retiredAt = Nothing,
          revokedAt = Nothing
        }
  runSess pool (Session.statement (keyRow pending) insertKeyStmt)
  putStrLn ("generated pending " <> Text.unpack (signingAlgorithmToText alg) <> " key: " <> Text.unpack pending.keyId)

-- | Promote a @pending@ key to @active@ and demote every prior @active@ key to @retired@,
-- atomically at one timestamp. Refuses if the key is not @pending@.
keysActivate :: Pool -> Text -> IO ()
keysActivate pool kid = do
  mk <- runSess pool (Session.statement kid findByKidStmt) >>= rebuildMaybe
  key <- requireKey kid mk
  unless (key.status == KeyPending) (die ("key " <> Text.unpack kid <> " is " <> show key.status <> ", expected pending"))
  now <- getCurrentTime
  outcome <- runTx pool do
    pending <- Tx.statement kid lockPendingStmt
    case pending of
      Nothing -> pure Nothing
      Just _ -> do
        oldKids <- Tx.statement now retireActiveStmt
        Tx.statement (kid, now) setActiveStmt
        pure (Just oldKids)
  retired <- maybe (die "key is no longer pending; no activation was applied") pure outcome
  putStrLn ("activated " <> Text.unpack kid)
  forM_ retired \oldKid -> putStrLn ("retired (auto) " <> Text.unpack oldKid)

-- | Demote an @active@ key to @retired@ (still trusted in the JWKS), stamping @retired_at@.
keysRetire :: Pool -> Text -> IO ()
keysRetire pool kid = do
  key <- requireKey kid =<< (runSess pool (Session.statement kid findByKidStmt) >>= rebuildMaybe)
  unless (key.status == KeyActive) (die ("key " <> Text.unpack kid <> " is " <> show key.status <> "; cannot retire"))
  now <- getCurrentTime
  runSess pool (Session.statement (kid, now) setRetiredStmt)
  putStrLn ("retired " <> Text.unpack kid)

-- | Mark a key @revoked@ (immediately untrusted, removed from the JWKS). Allowed from any
-- state except @revoked@.
keysRevoke :: Pool -> Text -> IO ()
keysRevoke pool kid = do
  key <- requireKey kid =<< (runSess pool (Session.statement kid findByKidStmt) >>= rebuildMaybe)
  unless (key.status `elem` [KeyPending, KeyActive, KeyRetired]) (die ("key " <> Text.unpack kid <> " is already revoked"))
  now <- getCurrentTime
  runSess pool (Session.statement (kid, now) setRevokedStmt)
  putStrLn ("revoked " <> Text.unpack kid)

-- | Re-wrap every key under a new KEK: decrypt with @oldKek@, encrypt with @newKek@. Any
-- All-or-nothing: the full decrypt pass runs in memory /before/ the first write, so a wrong
-- @SHOMEI_KEY_ENCRYPTION_KEY_OLD@ aborts having modified nothing. (A half-rewrapped table
-- would be unreadable by either KEK.)
keysRewrap :: KeyEncryptionKey -> KeyEncryptionKey -> Pool -> IO ()
keysRewrap oldKek newKek pool = do
  keys <- listAllKeys pool
  -- Pass 1: decrypt everything, or die before touching a row.
  decrypted <- traverse decryptOne keys
  -- Pass 2: re-encrypt every row before opening the all-or-nothing write transaction.
  rewrapped <- forM decrypted \(k, plain) -> do
    enc <- encryptPrivateJwk newKek k.keyId plain
    pure (k.keyId, enc)
  runTx pool (forM_ rewrapped \row -> Tx.statement row setPrivateKeyStmt)
  putStrLn ("rewrapped " <> show (length decrypted) <> " key(s)")
  where
    decryptOne k =
      case decryptPrivateJwk oldKek k.keyId k.privateKeyJwk of
        Right plain -> pure (k, plain)
        Left err ->
          die
            ( "cannot decrypt key "
                <> Text.unpack k.keyId
                <> " with SHOMEI_KEY_ENCRYPTION_KEY_OLD ("
                <> show err
                <> "); no rows were modified"
            )

-- | Print every key with kid / status / timestamps.
keysList :: Pool -> IO ()
keysList pool = do
  keys <- listAllKeys pool
  if null keys
    then putStrLn "(no signing keys)"
    else forM_ keys \k ->
      putStrLn
        ( Text.unpack k.keyId
            <> "\t"
            <> show k.status
            <> "\tcreated="
            <> show k.createdAt
            <> "\tactivated="
            <> show k.activatedAt
            <> "\tretired="
            <> show k.retiredAt
            <> "\trevoked="
            <> show k.revokedAt
        )

-- Read helpers (also used by the integration tests) --------------------------

-- | Keys that belong in the published JWKS: @active@ and @retired@ (overlap during rotation).
listPublishableSigningKeys :: Pool -> IO [StoredSigningKey]
listPublishableSigningKeys pool = runSess pool (Session.statement () listPublishableStmt) >>= traverse rebuild

listAllKeys :: Pool -> IO [StoredSigningKey]
listAllKeys pool = runSess pool (Session.statement () listAllStmt) >>= traverse rebuild

-- Internals ------------------------------------------------------------------

requireKey :: Text -> Maybe StoredSigningKey -> IO StoredSigningKey
requireKey kid = maybe (die ("no such key: " <> Text.unpack kid)) pure

die :: String -> IO a
die msg = hPutStrLn stderr ("shomei-admin: " <> msg) >> exitFailure

runSess :: Pool -> Session a -> IO a
runSess pool s = do
  res <- Pool.use pool s
  either (\e -> die (Text.unpack (summarizeUsageError e))) pure res

runTx :: Pool -> Transaction a -> IO a
runTx pool transaction = do
  res <- runEff . runDatabasePool pool $ runTransaction transaction
  case res of
    Right value -> pure value
    Left err
      | uniqueViolation err ->
          die "another key became active concurrently; run keys list and retry"
      | otherwise -> die (Text.unpack (summarizeUsageError err))
  where
    uniqueViolation = \case
      Pool.SessionUsageError
        ( Hasql.StatementSessionError
            _
            _
            _
            _
            _
            (Hasql.ServerStatementError (Hasql.ServerError "23505" _ _ _ _))
          ) -> True
      _ -> False

rebuild :: KeyRow -> IO StoredSigningKey
rebuild r = either (\e -> die ("corrupt key row: " <> Text.unpack e)) pure (rebuildKey r)

rebuildMaybe :: Maybe KeyRow -> IO (Maybe StoredSigningKey)
rebuildMaybe = traverse rebuild

-- Row mapping and statements (mirror Shomei.SigningKey.Postgres) ---------

type KeyRow = (Text, Text, Text, Text, Text, UTCTime, Maybe UTCTime, Maybe UTCTime, Maybe UTCTime)

keyRow :: StoredSigningKey -> KeyRow
keyRow k =
  ( k.keyId,
    k.algorithm,
    k.publicKeyJwk,
    k.privateKeyJwk,
    signingKeyStatusToText k.status,
    k.createdAt,
    k.activatedAt,
    k.retiredAt,
    k.revokedAt
  )

rebuildKey :: KeyRow -> Either Text StoredSigningKey
rebuildKey (kid, alg, pub, priv, st, c, act, ret, rev) = do
  status <- signingKeyStatusFromText st
  pure StoredSigningKey {keyId = kid, algorithm = alg, publicKeyJwk = pub, privateKeyJwk = priv, status = status, createdAt = c, activatedAt = act, retiredAt = ret, revokedAt = rev}

keyRowDecoder :: D.Row KeyRow
keyRowDecoder =
  (,,,,,,,,)
    <$> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.text)
    <*> D.column (D.nonNullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)
    <*> D.column (D.nullable D.timestamptz)

findByKidStmt :: Statement Text (Maybe KeyRow)
findByKidStmt =
  preparable
    """
    SELECT key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at
    FROM shomei.shomei_signing_keys WHERE key_id = $1
    """
    (E.param (E.nonNullable E.text))
    (D.rowMaybe keyRowDecoder)

listAllStmt :: Statement () [KeyRow]
listAllStmt =
  preparable
    """
    SELECT key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at
    FROM shomei.shomei_signing_keys ORDER BY created_at
    """
    E.noParams
    (D.rowList keyRowDecoder)

listPublishableStmt :: Statement () [KeyRow]
listPublishableStmt =
  preparable
    """
    SELECT key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at
    FROM shomei.shomei_signing_keys WHERE status IN ('active','retired') ORDER BY created_at
    """
    E.noParams
    (D.rowList keyRowDecoder)

insertKeyStmt :: Statement KeyRow ()
insertKeyStmt =
  preparable
    """
    INSERT INTO shomei.shomei_signing_keys
      (key_id, algorithm, public_key_jwk, private_key_jwk, status, created_at, activated_at, retired_at, revoked_at)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
    """
    ( contrazip9
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
        (E.param (E.nullable E.timestamptz))
        (E.param (E.nullable E.timestamptz))
        (E.param (E.nullable E.timestamptz))
    )
    D.noResult

-- | Replace a key's private material (encrypt-at-rest backfill and KEK rewrap). Never
-- touches @public_key_jwk@ or @status@.
setPrivateKeyStmt :: Statement (Text, Text) ()
setPrivateKeyStmt =
  preparable
    "UPDATE shomei.shomei_signing_keys SET private_key_jwk = $2 WHERE key_id = $1"
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)))
    D.noResult

setActiveStmt :: Statement (Text, UTCTime) ()
setActiveStmt =
  preparable
    "UPDATE shomei.shomei_signing_keys SET status = 'active', activated_at = $2 WHERE key_id = $1"
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))
    D.noResult

setRetiredStmt :: Statement (Text, UTCTime) ()
setRetiredStmt =
  preparable
    "UPDATE shomei.shomei_signing_keys SET status = 'retired', retired_at = $2 WHERE key_id = $1"
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))
    D.noResult

retireActiveStmt :: Statement UTCTime [Text]
retireActiveStmt =
  preparable
    """
    UPDATE shomei.shomei_signing_keys
    SET status = 'retired', retired_at = $1
    WHERE status = 'active'
    RETURNING key_id
    """
    (E.param (E.nonNullable E.timestamptz))
    (D.rowList (D.column (D.nonNullable D.text)))

setRevokedStmt :: Statement (Text, UTCTime) ()
setRevokedStmt =
  preparable
    "UPDATE shomei.shomei_signing_keys SET status = 'revoked', revoked_at = $2 WHERE key_id = $1"
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.timestamptz)))
    D.noResult

-- Revalidate and lock the source state inside the same transaction as retirement.
lockPendingStmt :: Statement Text (Maybe Text)
lockPendingStmt =
  preparable
    "SELECT key_id FROM shomei.shomei_signing_keys WHERE key_id=$1 AND status='pending' FOR UPDATE"
    (E.param (E.nonNullable E.text))
    (D.rowMaybe (D.column (D.nonNullable D.text)))