packages feed

shomei-webauthn-0.3.0.0: src/Shomei/WebAuthn/Ceremony.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE GADTs #-}

-- | The real 'WebAuthnCeremony' interpreter, backed by the @tweag/webauthn@ library
-- (MasterPlan 3, EP-1 M2).
--
-- This is the infrastructure side of the IP-1 port: it owns every @webauthn@ library type
-- (@shomei-core@ never names one). 'runWebAuthnCeremonyLibrary' closes over the
-- 'WebAuthnConfig' Relying Party identity and:
--
--   * /begin/ steps build the library's 'WA.CredentialOptions', encode them with
--     @webauthn-json@ to the browser-facing JSON ('optionsJson') and to an opaque
--     persisted blob ('optionsBlob', the same JSON as bytes — EP-2 stores it);
--   * /complete/ steps decode the persisted options blob (via the exposed-internal
--     'WJI.decode'), decode the browser's credential JSON, run the library's
--     'WA.verifyRegistrationResponse' / 'WA.verifyAuthenticationResponse', and map the
--     result (or the library's error families) to the small closed 'WebAuthnError' set.
--
-- A potentially-cloned signature counter fails closed ('WebAuthnCounterCloned'), per the
-- ADR-25. The Metadata Service registry is empty ('mempty') and the registration trust
-- verdict is not used. @direct@ requests attestation conveyance; it does not require
-- trusted hardware or apply a device allow-list. @none@ remains the default.
--
-- Note: 'WebAuthnConfig' fields are read via their plain selectors (@rpId cfg@, …) rather
-- than @cfg.rpId@ record-dot, because GHC does not derive @HasField@ for these records
-- under @DuplicateRecordFields@.
module Shomei.WebAuthn.Ceremony (runWebAuthnCeremonyLibrary) where

import Control.Monad.Except (runExcept)
import Crypto.Hash (hash)
import Crypto.WebAuthn qualified as WA
import Crypto.WebAuthn.Encoding.Internal.WebAuthnJson qualified as WJI
import Crypto.WebAuthn.Encoding.Strings qualified as WStr
import Crypto.WebAuthn.Encoding.WebAuthnJson qualified as WJ
import Data.Aeson (Value, eitherDecodeStrict', encode, fromJSON, toJSON)
import Data.Aeson qualified as Aeson
import Data.Bifunctor (first)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as LBS
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NE
import Data.Text qualified as Text
import Data.Text.Encoding (encodeUtf8)
import Data.Time (NominalDiffTime)
import Data.Validation (Validation (Failure, Success))
import Effectful (Eff, IOE, liftIO, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Shomei.Config
  ( AttestationPolicy (..),
    UserVerificationPolicy (..),
    WebAuthnConfig,
    attestation,
    ceremonyTimeout,
    origins,
    rpId,
    rpName,
    userVerification,
  )
import Shomei.Passkey.Ceremony.Port
  ( BeginCeremony (..),
    CredentialUserInfo (..),
    StoredCredentialForVerify (..),
    VerifiedAuthentication (..),
    VerifiedRegistration (..),
    WebAuthnCeremony (..),
    WebAuthnError (..),
  )
import Shomei.Passkey.Domain
  ( PublicKeyBytes (..),
    SignatureCounter (..),
    UserHandle (..),
    WebAuthnCredentialId (..),
  )
import Time.System (dateCurrent)

runWebAuthnCeremonyLibrary :: (IOE :> es) => WebAuthnConfig -> Eff (WebAuthnCeremony : es) a -> Eff es a
runWebAuthnCeremonyLibrary cfg = interpret_ \case
  BeginRegistrationCeremony userInfo excludeIds -> liftIO (beginRegistration cfg userInfo excludeIds)
  CompleteRegistrationCeremony blob credJson -> liftIO (completeRegistration cfg blob credJson)
  BeginAuthenticationCeremony uv allowIds -> liftIO (beginAuthentication cfg uv allowIds)
  CompleteAuthenticationCeremony blob stored credJson ->
    pure (completeAuthentication cfg blob stored credJson)

-- Begin steps -----------------------------------------------------------------

beginRegistration :: WebAuthnConfig -> CredentialUserInfo -> [WebAuthnCredentialId] -> IO BeginCeremony
beginRegistration cfg (CredentialUserInfo uh accountName displayName) excludeIds = do
  challenge <- WA.generateChallenge
  let opts =
        WA.CredentialOptionsRegistration
          { WA.corRp =
              WA.CredentialRpEntity
                { WA.creId = Just (WA.RpId (rpId cfg)),
                  WA.creName = WA.RelyingPartyName (rpName cfg)
                },
            WA.corUser =
              WA.CredentialUserEntity
                { WA.cueId = toWAUserHandle uh,
                  WA.cueDisplayName = WA.UserAccountDisplayName displayName,
                  WA.cueName = WA.UserAccountName accountName
                },
            WA.corChallenge = challenge,
            WA.corPubKeyCredParams =
              [ WA.CredentialParameters {WA.cpTyp = WA.CredentialTypePublicKey, WA.cpAlg = WA.CoseAlgorithmES256},
                WA.CredentialParameters {WA.cpTyp = WA.CredentialTypePublicKey, WA.cpAlg = WA.CoseAlgorithmRS256}
              ],
            WA.corTimeout = Just (mkTimeout (ceremonyTimeout cfg)),
            WA.corExcludeCredentials = map mkDescriptor excludeIds,
            WA.corAuthenticatorSelection =
              Just
                WA.AuthenticatorSelectionCriteria
                  { WA.ascAuthenticatorAttachment = Nothing,
                    WA.ascResidentKey = WA.ResidentKeyRequirementDiscouraged,
                    WA.ascUserVerification = mapUV (userVerification cfg)
                  },
            WA.corHints = [],
            WA.corAttestation = mapAttestation (attestation cfg),
            WA.corExtensions = Nothing
          }
      wjOpts = WJ.wjEncodeCredentialOptionsRegistration opts
  pure BeginCeremony {optionsJson = toJSON wjOpts, optionsBlob = LBS.toStrict (encode wjOpts)}

beginAuthentication :: WebAuthnConfig -> UserVerificationPolicy -> [WebAuthnCredentialId] -> IO BeginCeremony
beginAuthentication cfg uv allowIds = do
  challenge <- WA.generateChallenge
  let opts =
        WA.CredentialOptionsAuthentication
          { WA.coaRpId = Just (WA.RpId (rpId cfg)),
            WA.coaTimeout = Just (mkTimeout (ceremonyTimeout cfg)),
            WA.coaChallenge = challenge,
            WA.coaAllowCredentials = map mkDescriptor allowIds,
            WA.coaUserVerification = mapUV uv,
            WA.coaHints = [],
            WA.coaExtensions = Nothing
          }
      wjOpts = WJ.wjEncodeCredentialOptionsAuthentication opts
  pure BeginCeremony {optionsJson = toJSON wjOpts, optionsBlob = LBS.toStrict (encode wjOpts)}

-- Complete steps --------------------------------------------------------------

completeRegistration :: WebAuthnConfig -> ByteString -> Value -> IO (Either WebAuthnError VerifiedRegistration)
completeRegistration cfg blob credJson = do
  now <- dateCurrent
  pure do
    allowed <- maybe (Left (WebAuthnOtherError "no allowed WebAuthn origins are configured")) Right (originsOf cfg)
    opts <- recoverRegistrationOptions blob
    cred <- decodeRegistrationCredential credJson
    case WA.verifyRegistrationResponse allowed (rpIdHashOf cfg) mempty now opts cred of
      Failure errs -> Left (mapRegError (NE.head errs))
      Success result -> Right (entryToRegistration (WA.rrEntry result))

completeAuthentication ::
  WebAuthnConfig ->
  ByteString ->
  StoredCredentialForVerify ->
  Value ->
  Either WebAuthnError VerifiedAuthentication
completeAuthentication cfg blob (StoredCredentialForVerify scid suh spk scnt strans) credJson = do
  allowed <- maybe (Left (WebAuthnOtherError "no allowed WebAuthn origins are configured")) Right (originsOf cfg)
  opts <- recoverAuthenticationOptions blob
  cred <- decodeAuthenticationCredential credJson
  let entry =
        WA.CredentialEntry
          { WA.ceCredentialId = toWACredId scid,
            WA.ceUserHandle = toWAUserHandle suh,
            WA.cePublicKeyBytes = toWAPubKey spk,
            WA.ceSignCounter = toWACounter scnt,
            WA.ceTransports = map WStr.decodeAuthenticatorTransport strans
          }
  case WA.verifyAuthenticationResponse allowed (rpIdHashOf cfg) (Just (toWAUserHandle suh)) entry opts cred of
    Failure errs -> Left (mapAuthError (NE.head errs))
    Success (WA.AuthenticationResult counterResult) -> case counterResult of
      WA.SignatureCounterZero -> Right (verifiedAuth scid scnt)
      WA.SignatureCounterUpdated c -> Right (verifiedAuth scid (SignatureCounter (WA.unSignatureCounter c)))
      WA.SignatureCounterPotentiallyCloned -> Left WebAuthnCounterCloned
  where
    verifiedAuth cid newCounter =
      VerifiedAuthentication {credentialId = cid, newSignCounter = newCounter}

-- Options / credential (de)serialization --------------------------------------

recoverRegistrationOptions :: ByteString -> Either WebAuthnError (WA.CredentialOptions 'WA.Registration)
recoverRegistrationOptions blob = do
  wjOpts <- first (WebAuthnDecodeError . Text.pack) (eitherDecodeStrict' blob)
  first WebAuthnDecodeError (runExcept (WJI.decode (WJ._unWJCredentialOptionsRegistration wjOpts)))

recoverAuthenticationOptions :: ByteString -> Either WebAuthnError (WA.CredentialOptions 'WA.Authentication)
recoverAuthenticationOptions blob = do
  wjOpts <- first (WebAuthnDecodeError . Text.pack) (eitherDecodeStrict' blob)
  first WebAuthnDecodeError (runExcept (WJI.decode (WJ._unWJCredentialOptionsAuthentication wjOpts)))

decodeRegistrationCredential :: Value -> Either WebAuthnError (WA.Credential 'WA.Registration 'True)
decodeRegistrationCredential v = do
  wjCred <- case fromJSON v of
    Aeson.Success c -> Right c
    Aeson.Error e -> Left (WebAuthnDecodeError (Text.pack e))
  first (WebAuthnDecodeError . Text.pack . show) (WJ.wjDecodeCredentialRegistration wjCred)

decodeAuthenticationCredential :: Value -> Either WebAuthnError (WA.Credential 'WA.Authentication 'True)
decodeAuthenticationCredential v = do
  wjCred <- case fromJSON v of
    Aeson.Success c -> Right c
    Aeson.Error e -> Left (WebAuthnDecodeError (Text.pack e))
  first (WebAuthnDecodeError . Text.pack . show) (WJ.wjDecodeCredentialAuthentication wjCred)

entryToRegistration :: WA.CredentialEntry -> VerifiedRegistration
entryToRegistration e =
  VerifiedRegistration
    { credentialId = WebAuthnCredentialId (WA.unCredentialId (WA.ceCredentialId e)),
      userHandle = UserHandle (WA.unUserHandle (WA.ceUserHandle e)),
      publicKey = PublicKeyBytes (WA.unPublicKeyBytes (WA.cePublicKeyBytes e)),
      signCounter = SignatureCounter (WA.unSignatureCounter (WA.ceSignCounter e)),
      transports = map WStr.encodeAuthenticatorTransport (WA.ceTransports e)
    }

-- RP identity / policy mapping ------------------------------------------------

originsOf :: WebAuthnConfig -> Maybe (NonEmpty WA.Origin)
originsOf cfg = NE.nonEmpty (map WA.Origin (origins cfg))

rpIdHashOf :: WebAuthnConfig -> WA.RpIdHash
rpIdHashOf cfg = WA.RpIdHash (hash (encodeUtf8 (rpId cfg)))

mkTimeout :: NominalDiffTime -> WA.Timeout
mkTimeout dt = WA.Timeout (round (realToFrac dt * 1000 :: Double))

mkDescriptor :: WebAuthnCredentialId -> WA.CredentialDescriptor
mkDescriptor cid =
  WA.CredentialDescriptor
    { WA.cdTyp = WA.CredentialTypePublicKey,
      WA.cdId = toWACredId cid,
      WA.cdTransports = Nothing
    }

mapUV :: UserVerificationPolicy -> WA.UserVerificationRequirement
mapUV UVRequired = WA.UserVerificationRequirementRequired
mapUV UVPreferred = WA.UserVerificationRequirementPreferred
mapUV UVDiscouraged = WA.UserVerificationRequirementDiscouraged

mapAttestation :: AttestationPolicy -> WA.AttestationConveyancePreference
mapAttestation AttestationNone = WA.AttestationConveyancePreferenceNone
mapAttestation AttestationDirect = WA.AttestationConveyancePreferenceDirect

-- Type bridges (Shōmei domain <-> webauthn newtypes) --------------------------

toWAUserHandle :: UserHandle -> WA.UserHandle
toWAUserHandle (UserHandle bs) = WA.UserHandle bs

toWACredId :: WebAuthnCredentialId -> WA.CredentialId
toWACredId (WebAuthnCredentialId bs) = WA.CredentialId bs

toWAPubKey :: PublicKeyBytes -> WA.PublicKeyBytes
toWAPubKey (PublicKeyBytes bs) = WA.PublicKeyBytes bs

toWACounter :: SignatureCounter -> WA.SignatureCounter
toWACounter (SignatureCounter w) = WA.SignatureCounter w

-- Error mapping ---------------------------------------------------------------

mapRegError :: WA.RegistrationError -> WebAuthnError
mapRegError = \case
  WA.RegistrationChallengeMismatch {} -> WebAuthnChallengeMismatch
  WA.RegistrationOriginMismatch {} -> WebAuthnOriginMismatch
  WA.RegistrationRpIdHashMismatch {} -> WebAuthnRpIdMismatch
  WA.RegistrationUserNotPresent {} -> WebAuthnUserNotPresent
  WA.RegistrationUserNotVerified {} -> WebAuthnUserNotVerified
  err -> WebAuthnOtherError (Text.pack (show err))

mapAuthError :: WA.AuthenticationError -> WebAuthnError
mapAuthError = \case
  WA.AuthenticationChallengeMismatch {} -> WebAuthnChallengeMismatch
  WA.AuthenticationOriginMismatch {} -> WebAuthnOriginMismatch
  WA.AuthenticationRpIdHashMismatch {} -> WebAuthnRpIdMismatch
  WA.AuthenticationUserNotPresent {} -> WebAuthnUserNotPresent
  WA.AuthenticationUserNotVerified {} -> WebAuthnUserNotVerified
  WA.AuthenticationSignatureInvalid {} -> WebAuthnSignatureInvalid
  err -> WebAuthnOtherError (Text.pack (show err))