packages feed

webauthn-0.6.0.0: tests/Emulation.hs

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

module Emulation
  ( spec,
  )
where

import Control.Monad.Except (ExceptT (ExceptT), MonadError, runExceptT, throwError)
import Control.Monad.Trans (MonadTrans (lift))
import Crypto.Hash (hash)
import qualified Crypto.Random as Random
import qualified Crypto.WebAuthn.Cose.SignAlg as Cose
import qualified Crypto.WebAuthn.Metadata.Service.Types as Meta
import qualified Crypto.WebAuthn.Model as M
import qualified Crypto.WebAuthn.Operation as O
import Data.Bifunctor (Bifunctor (second))
import Data.Hourglass (DateTime)
import qualified Data.List.NonEmpty as NE
import qualified Data.Set as Set
import Data.Text.Encoding (encodeUtf8)
import Data.Validation (toEither)
import Emulation.Authenticator
  ( Authenticator (AuthenticatorNone, aAuthenticatorDataFlags, aConformance, aSignatureCounter),
    AuthenticatorNonConformingBehaviour (RandomPrivateKey, RandomSignatureData, StaticCounter),
    AuthenticatorSignatureCounter (Unsupported),
  )
import Emulation.Authenticator.Arbitrary ()
import Emulation.Client
  ( AnnotatedOrigin (AnnotatedOrigin, aoOrigin, aoRpId),
    UserAgentConformance,
    UserAgentNonConformingBehaviour (RandomChallenge),
    clientAssertion,
    clientAttestation,
  )
import Emulation.Client.Arbitrary ()
import Spec.Util (predeterminedDateTime)
import Test.Hspec (SpecWith, describe, it, shouldSatisfy)
import Test.QuickCheck (property)

-- | Custom type to combine the MonadPseudoRandom with the Except monad. We
-- force the ChaChaDRG to ensure the App type is completely pure, and
-- evaluating the monad has no side effects.
newtype App a = App (ExceptT String (Random.MonadPseudoRandom Random.ChaChaDRG) a)
  deriving newtype (Functor, Applicative, Monad, MonadError String)

instance MonadFail App where
  fail = throwError

instance Random.MonadRandom App where
  getRandomBytes n = App $ lift $ Random.getRandomBytes n

runApp :: Integer -> App a -> Either String a
runApp seed (App except) =
  let rng = Random.drgNewSeed $ Random.seedFromInteger seed
   in fst $ Random.withDRG rng $ runExceptT except

-- | Performs attestation to generate a credential, and then uses that
-- credential to perform assertion.
register ::
  (Random.MonadRandom m, MonadFail m) =>
  AnnotatedOrigin ->
  UserAgentConformance ->
  Authenticator ->
  Meta.MetadataServiceRegistry ->
  DateTime ->
  m (Either (NE.NonEmpty O.RegistrationError) O.RegistrationResult, Authenticator, M.CredentialOptions 'M.Registration)
register ao conformance authenticator registry now = do
  -- Generate new random input
  assertionChallenge <- M.generateChallenge
  userId <- M.generateUserHandle
  -- Create dummy user
  let user =
        M.CredentialUserEntity
          { M.cueId = userId,
            M.cueDisplayName = M.UserAccountDisplayName "John Doe",
            M.cueName = M.UserAccountName "john-doe"
          }
  let options = defaultPkcoc user assertionChallenge
  -- Perform client Attestation emulation with a fresh authenticator
  (mPkcCreate, authenticator) <- clientAttestation options ao conformance authenticator
  -- Verify the result
  let registerResult =
        toEither $
          O.verifyRegistrationResponse
            (aoOrigin ao)
            (M.RpIdHash . hash . encodeUtf8 . M.unRpId $ aoRpId ao)
            registry
            now
            options
            mPkcCreate
  pure (registerResult, authenticator, options)

login ::
  (Random.MonadRandom m, MonadFail m) =>
  AnnotatedOrigin ->
  UserAgentConformance ->
  Authenticator ->
  O.CredentialEntry ->
  m (Either (NE.NonEmpty O.AuthenticationError) O.SignatureCounterResult)
login ao conformance authenticator ce@O.CredentialEntry {..} = do
  attestationChallenge <- M.generateChallenge
  let options = defaultCog attestationChallenge
  -- Perform client assertion emulation with the same authenticator, this
  -- authenticator should now store the created credential
  (mPkcGet, _) <- clientAssertion options ao conformance authenticator
  pure
    . second O.arSignatureCounterResult
    . toEither
    $ O.verifyAuthenticationResponse
      (aoOrigin ao)
      (M.RpIdHash . hash . encodeUtf8 . M.unRpId $ aoRpId ao)
      (Just ceUserHandle)
      ce
      options
      mPkcGet

spec :: SpecWith ()
spec =
  describe "None" $
    it "succeeds" $
      property $ \seed authenticator userAgentConformance -> do
        let annotatedOrigin =
              AnnotatedOrigin
                { aoRpId = M.RpId "localhost",
                  aoOrigin = M.Origin "https://localhost:8080"
                }
        -- Since our emulator only supports None attestation the registry can be left empty.
        let registry = mempty
        -- We are not currently interested in client or authenticator fails, we
        -- only wish to test our relying party implementation and are thus only
        -- interested in its errors.
        let Right (registerResult, authenticator', options) = runApp seed (register annotatedOrigin userAgentConformance authenticator registry predeterminedDateTime)
        -- Since we only do None attestation, we only care about the resulting entry
        let registerResult' = second O.rrEntry registerResult
        registerResult' `shouldSatisfy` validAttestationResult authenticator userAgentConformance options
        -- Only if attestation succeeded can we continue with assertion
        case registerResult' of
          Right credentialEntry -> do
            let Right loginResult = runApp (seed + 1) (login annotatedOrigin userAgentConformance authenticator' credentialEntry)
            loginResult `shouldSatisfy` validAssertionResult authenticator userAgentConformance
          _ -> pure ()

-- | Validates the result of attestation. Ensures that the proper errors are
-- resulted in if the authenticator exhibits nonconforming behaviour, and
-- checks if the correct result was given if the authenticator does not exhibit
-- any nonconforming behaviour.
validAttestationResult :: Authenticator -> UserAgentConformance -> M.CredentialOptions 'M.Registration -> Either (NE.NonEmpty O.RegistrationError) O.CredentialEntry -> Bool
-- A valid result can only happen if we exhibited no non-conforming behaviour
-- The userHandle must be the one specified by the options
validAttestationResult _ _ M.CredentialOptionsRegistration {..} (Right O.CredentialEntry {..}) = ceUserHandle == M.cueId corUser
-- If we did result in errors, we want every error to be validated by some
-- configuration issue (NOTE: We cannot currently exhibit non conforming
-- behaviour during attestation)
validAttestationResult AuthenticatorNone {..} uaConformance _ (Left errors) = all isValidated errors
  where
    isValidated :: O.RegistrationError -> Bool
    isValidated (O.RegistrationChallengeMismatch _ _) = RandomChallenge `elem` uaConformance
    isValidated (O.RegistrationOriginMismatch _ _) = False
    isValidated (O.RegistrationRpIdHashMismatch _ _) = False
    -- The User not being present must be a result of the authenticator not checking for a user being present
    isValidated O.RegistrationUserNotPresent = not $ M.adfUserPresent aAuthenticatorDataFlags
    -- The User not being valided must be a result of the authenticator not validating the user
    isValidated O.RegistrationUserNotVerified = not $ M.adfUserVerified aAuthenticatorDataFlags
    isValidated (O.RegistrationPublicKeyAlgorithmDisallowed _ _) = False
    isValidated (O.RegistrationAttestationFormatError _ _) = False

-- | Validates the result of assertion. Ensures that the proper errors are
-- resulted in if the authenticator exhibits nonconforming behaviour, and
-- checks if the correct result was given if the authenticator does not exhibit
-- any nonconforming behaviour.
validAssertionResult :: Authenticator -> UserAgentConformance -> Either (NE.NonEmpty O.AuthenticationError) O.SignatureCounterResult -> Bool
-- We can only result in a 0 signature counter if the authenticator doesn't
-- have a counter and is either conforming or only has a static counter
validAssertionResult AuthenticatorNone {..} _ (Right O.SignatureCounterZero) =
  aSignatureCounter == Unsupported && (Set.null aConformance || aConformance == Set.singleton StaticCounter)
-- A valid response must only happen if we have no non-confirming behaviour
validAssertionResult AuthenticatorNone {..} _ (Right (O.SignatureCounterUpdated _)) = Set.null aConformance
-- A potentially cloned counter must imply that we only exhibited the static
-- counter non-conforming behaviour
validAssertionResult AuthenticatorNone {..} _ (Right O.SignatureCounterPotentiallyCloned) = Set.singleton StaticCounter == aConformance
-- If we did result in errors, we want every error to be validated by some
-- non-conforming behaviour or configuration issue
validAssertionResult AuthenticatorNone {..} uaConformance (Left errors) = all isValidated errors
  where
    isValidated :: O.AuthenticationError -> Bool
    isValidated (O.AuthenticationCredentialDisallowed _ _) = False
    isValidated (O.AuthenticationIdentifiedUserHandleMismatch _ _) = False
    isValidated (O.AuthenticationCredentialUserHandleMismatch _ _) = False
    isValidated O.AuthenticationCannotVerifyUserHandle = False
    isValidated (O.AuthenticationChallengeMismatch _ _) = RandomChallenge `elem` uaConformance
    isValidated (O.AuthenticationOriginMismatch _ _) = False
    isValidated (O.AuthenticationRpIdHashMismatch _ _) = False
    -- The User not being present must be a result of the authenticator not checking for a user being present
    isValidated O.AuthenticationUserNotPresent = not $ M.adfUserPresent aAuthenticatorDataFlags
    -- The User not being valided must be a result of the authenticator not validating the user
    isValidated O.AuthenticationUserNotVerified = not $ M.adfUserVerified aAuthenticatorDataFlags
    -- The Signature being invalid can happen when the data was wrong or the wrong private key was used
    isValidated (O.AuthenticationSignatureDecodingError _) = False
    isValidated O.AuthenticationSignatureInvalid {} = elem RandomSignatureData aConformance || elem RandomPrivateKey aConformance

-- | Create a default set of options for attestation. These options can be modified before using them in the tests
defaultPkcoc :: M.CredentialUserEntity -> M.Challenge -> M.CredentialOptions 'M.Registration
defaultPkcoc userEntity challenge =
  M.CredentialOptionsRegistration
    { M.corRp = M.CredentialRpEntity {M.creId = Nothing, M.creName = "ACME"},
      M.corUser = userEntity,
      M.corChallenge = challenge,
      -- Empty credentialparameters are not supported.
      M.corPubKeyCredParams =
        [ M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmES256
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmES384
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmES512
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmEdDSA
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmRS1
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmRS256
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmRS384
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmRS512
            }
        ],
      M.corTimeout = Nothing,
      M.corExcludeCredentials = [],
      M.corAuthenticatorSelection =
        Just
          M.AuthenticatorSelectionCriteria
            { M.ascAuthenticatorAttachment = Nothing,
              M.ascResidentKey = M.ResidentKeyRequirementDiscouraged,
              M.ascUserVerification = M.UserVerificationRequirementPreferred
            },
      M.corAttestation = M.AttestationConveyancePreferenceDirect,
      M.corExtensions = Nothing
    }

-- | Create a default set of options for assertion. These options can be modified before using them in the tests
defaultCog :: M.Challenge -> M.CredentialOptions 'M.Authentication
defaultCog challenge =
  M.CredentialOptionsAuthentication
    { M.coaChallenge = challenge,
      M.coaTimeout = Nothing,
      M.coaRpId = Just "localhost",
      -- We currently only support client-side discoverable credentials
      M.coaAllowCredentials = [],
      M.coaUserVerification = M.UserVerificationRequirementPreferred,
      M.coaExtensions = Nothing
    }