packages feed

webauthn-0.4.1.0: tests/Main.hs

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

module Main
  ( main,
  )
where

import Crypto.Hash (hash)
import qualified Crypto.WebAuthn.Cose.SignAlg as Cose
import qualified Crypto.WebAuthn.Encoding.WebAuthnJson as WJ
import qualified Crypto.WebAuthn.Metadata as Meta
import qualified Crypto.WebAuthn.Metadata.Service.Types as Service
import qualified Crypto.WebAuthn.Model as M
import qualified Crypto.WebAuthn.Operation as O
import Data.Aeson (FromJSON)
import qualified Data.Aeson as Aeson
import qualified Data.ByteString as BS
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Lazy as LazyByteString
import Data.Either (isRight)
import Data.Foldable (for_)
import qualified Data.Hourglass as HG
import Data.List.NonEmpty (NonEmpty)
import qualified Data.Text as Text
import Data.Text.Encoding (encodeUtf8)
import Data.Validation (toEither)
import qualified Emulation
import qualified Encoding
import GHC.Stack (HasCallStack)
import qualified MetadataSpec
import qualified PublicKeySpec
import Spec.Util (decodeFile, predeterminedDateTime, timeZero)
import qualified System.Directory as Directory
import System.FilePath ((</>))
import Test.Hspec (Spec, describe, it, shouldSatisfy)
import qualified Test.Hspec as Hspec
import Test.QuickCheck.Instances.Text ()

-- | Load all files in the given directory, and ensure that all of them can be
-- decoded. The caller can pass in a function to run further checks on the
-- decoded value, but this is mainly there to ensure that `a` occurs after the
-- fat arrow.
canDecodeAllToJSRepr :: forall a. (FromJSON a, HasCallStack) => FilePath -> Spec
canDecodeAllToJSRepr path = do
  files <- Hspec.runIO $ Directory.listDirectory path
  for_ files $ \fname ->
    it ("can decode " <> (path </> fname)) $ do
      bytes <- ByteString.readFile $ path </> fname
      case Aeson.eitherDecode' @a $ LazyByteString.fromStrict bytes of
        Left err -> fail err
        Right _value -> pure ()

-- | During tests, we need access to the metadata to verify the attestation of the test registration message.
-- We use the blob we also use for metadata parsing tests.
registryFromBlobFile :: IO Service.MetadataServiceRegistry
registryFromBlobFile = do
  blobBytes <- BS.readFile "tests/golden-metadata/big/blob.jwt"
  case Meta.metadataBlobToRegistry blobBytes predeterminedDateTime of
    Left err -> error $ Text.unpack err
    Right res -> pure res

-- | Given a JSON Message in a file, performs attestation.
-- The Boolean argument denotes if the attestation message can be verified using the metadata service.
-- This is because some of our tests cannot be verfied (for different reasons).
registerTestFromFile :: FilePath -> M.Origin -> M.RpId -> Bool -> Service.MetadataServiceRegistry -> HG.DateTime -> IO ()
registerTestFromFile fp origin rpId verifiable service now = do
  pkCredential <-
    either (error . show) id . WJ.wjDecodeCredentialRegistration
      <$> decodeFile fp
  let options = defaultPublicKeyCredentialCreationOptions pkCredential
  let registerResult =
        toEither $
          O.verifyRegistrationResponse
            origin
            (M.RpIdHash . hash . encodeUtf8 . M.unRpId $ rpId)
            service
            now
            options
            pkCredential
  registerResult `shouldSatisfy` isExpectedAttestationResponse pkCredential options verifiable

main :: IO ()
main = Hspec.hspec $ do
  registry <- Hspec.runIO registryFromBlobFile
  describe "Decode test responses" $ do
    -- Check if all attestation responses can be decoded
    describe "attestation responses" $
      canDecodeAllToJSRepr
        @WJ.WJCredentialRegistration
        "tests/responses/attestation/"
    -- Check if all assertion responses can be decoded
    describe "assertion responses" $
      canDecodeAllToJSRepr
        @WJ.WJCredentialAuthentication
        "tests/responses/assertion/"
  -- Test public key related tests
  describe "PublicKey" PublicKeySpec.spec
  describe
    "Metadata"
    MetadataSpec.spec
  describe
    "Emulation"
    Emulation.spec
  describe
    "Encoding"
    Encoding.spec
  -- We test assertion only for none attestation, this is because the type of attestation has no influence on assertion.
  describe "RegisterAndLogin" $ do
    it "tests whether the fixed register and login responses are matching" $
      do
        pkCredential <-
          either (error . show) id . WJ.wjDecodeCredentialRegistration
            <$> decodeFile
              "tests/responses/attestation/01-none.json"
        let options = defaultPublicKeyCredentialCreationOptions pkCredential
            registerResult =
              toEither $
                O.verifyRegistrationResponse
                  (M.Origin "http://localhost:8080")
                  (M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
                  registry
                  predeterminedDateTime
                  options
                  pkCredential
        registerResult `shouldSatisfy` isExpectedAttestationResponse pkCredential options False
        let Right O.RegistrationResult {O.rrEntry = credentialEntry} = registerResult
        loginReq <-
          either (error . show) id . WJ.wjDecodeCredentialAuthentication
            <$> decodeFile
              @WJ.WJCredentialAuthentication
              "tests/responses/assertion/01-none.json"
        let M.Credential {M.cResponse = cResponse} = loginReq
            signInResult =
              toEither $
                O.verifyAuthenticationResponse
                  (M.Origin "http://localhost:8080")
                  (M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
                  (Just (M.UserHandle "UserId"))
                  credentialEntry
                  (defaultPublicKeyCredentialRequestOptions loginReq)
                  M.Credential
                    { M.cIdentifier = O.ceCredentialId credentialEntry,
                      M.cResponse = cResponse,
                      M.cClientExtensionResults = M.AuthenticationExtensionsClientOutputs {}
                    }
        signInResult `shouldSatisfy` isRight
    it "tests whether the fixed register and login responses are matching with empty user handle" $
      do
        pkCredential <-
          either (error . show) id . WJ.wjDecodeCredentialRegistration
            <$> decodeFile
              "tests/responses/attestation/01-none.json"
        let options = defaultPublicKeyCredentialCreationOptions pkCredential
            registerResult =
              toEither $
                O.verifyRegistrationResponse
                  (M.Origin "http://localhost:8080")
                  (M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
                  registry
                  predeterminedDateTime
                  options
                  pkCredential
        registerResult `shouldSatisfy` isExpectedAttestationResponse pkCredential options False
        let Right O.RegistrationResult {O.rrEntry = credentialEntry} = registerResult
        loginReq <-
          either (error . show) id . WJ.wjDecodeCredentialAuthentication
            <$> decodeFile
              @WJ.WJCredentialAuthentication
              "tests/responses/assertion/01-none-empty-user-handle.json"
        let M.Credential {M.cResponse = cResponse} = loginReq
            signInResult =
              toEither $
                O.verifyAuthenticationResponse
                  (M.Origin "http://localhost:8080")
                  (M.RpIdHash . hash $ ("localhost" :: ByteString.ByteString))
                  (Just (M.UserHandle "UserId"))
                  credentialEntry
                  (defaultPublicKeyCredentialRequestOptions loginReq)
                  M.Credential
                    { M.cIdentifier = O.ceCredentialId credentialEntry,
                      M.cResponse = cResponse,
                      M.cClientExtensionResults = M.AuthenticationExtensionsClientOutputs {}
                    }
        signInResult `shouldSatisfy` isRight
  describe "Packed register" $ do
    it "tests whether the fixed packed register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/packed-01.json"
        "https://localhost:44329"
        "localhost"
        -- This attestation response appears to be from a Feitian security key,
        -- however the returned AAGUID is not registered in the FIDO Metadata
        -- service, even though there are a lot of other Feitian keys there.
        -- While the chain of the attestation statement contains the root
        -- certificate, we can't find an official publication of any root
        -- certificate, therefore making this attestation questionable. Perhaps
        -- it is from an older Feitian model not supported anymore
        False
        registry
        predeterminedDateTime
    it "tests whether the fixed packed register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/packed-02.json"
        "http://localhost:5000"
        "localhost"
        True
        registry
        predeterminedDateTime
    it "tests whether the fixed packed register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/packed-03.json"
        "http://localhost:5000"
        "localhost"
        True
        registry
        predeterminedDateTime
    it "regression test for #150" $
      registerTestFromFile
        "tests/responses/attestation/regression-150.json"
        "https://webauthn.dev.tweag.io"
        "webauthn.dev.tweag.io"
        False
        registry
        predeterminedDateTime
    it "the response with transports information works" $
      registerTestFromFile
        "tests/responses/attestation/with-transports.json"
        "https://infinisil.webauthn.dev.tweag.io"
        "infinisil.webauthn.dev.tweag.io"
        True
        registry
        predeterminedDateTime
  describe "AndroidKey register" $ do
    it "tests whether the fixed android key register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/android-key-01.json"
        "https://localhost:44329"
        "localhost"
        False -- Uses a fake certificate in the chain
        registry
        predeterminedDateTime
    it "tests whether the fixed android key register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/android-key-02.json"
        "https://dev.dontneeda.pw"
        "dev.dontneeda.pw"
        False -- Uses a fake certificate in the chain
        registry
        predeterminedDateTime
  describe "AndroidSafetyNet register" $ do
    it "tests whether the fixed android safetynet register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/android-safetynet-01.json"
        "https://devicemanagement-duo1.pwl.ngrok.io"
        "pwl.ngrok.io"
        True
        registry
        HG.DateTime
          { dtDate = HG.Date {dateYear = 2021, dateMonth = HG.September, dateDay = 3},
            dtTime = HG.TimeOfDay {todHour = HG.Hours 21, todMin = HG.Minutes 7, todSec = HG.Seconds 21, todNSec = HG.NanoSeconds 0}
          }
  describe "U2F register" $ do
    it "tests whether the fixed fido-u2f register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/u2f-01.json"
        "https://localhost:44329"
        "localhost"
        True
        registry
        predeterminedDateTime
    it "tests whether the fixed fido-u2f register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/u2f-02.json"
        "http://localhost:5000"
        "localhost"
        True
        registry
        predeterminedDateTime
    it "tests whether the fixed fido-u2f register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/u2f-03.json"
        "http://localhost:5000"
        "localhost"
        False -- Uses a FIDO test key
        registry
        predeterminedDateTime
    it "tests whether the fixed fido-u2f register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/u2f-04.json"
        "https://api-duo1.duo.test"
        "duo.test"
        True
        registry
        predeterminedDateTime
    it "tests whether the fixed fido-u2f register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/u2f-05.json"
        "https://api-duo1.duo.test"
        "duo.test"
        True
        registry
        predeterminedDateTime
  describe "Apple register" $ do
    it "tests whether the fixed apple register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/apple-01.json"
        "https://6cc3c9e7967a.ngrok.io"
        "6cc3c9e7967a.ngrok.io"
        True
        registry
        HG.DateTime {dtDate = HG.Date {dateYear = 2020, dateMonth = HG.October, dateDay = 8}, dtTime = timeZero}
    it "tests whether the fixed apple register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/apple-02.json"
        "https://dev2.dontneeda.pw:5000"
        "dev2.dontneeda.pw"
        True
        registry
        HG.DateTime {dtDate = HG.Date {dateYear = 2021, dateMonth = HG.September, dateDay = 1}, dtTime = timeZero}
  describe "TPM register" $ do
    it "tests whether the fixed TPM-SHA1 register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/tpm-rs1-01.json"
        "https://webauthntest.azurewebsites.net"
        "webauthntest.azurewebsites.net"
        True
        registry
        predeterminedDateTime
    it "tests whether the fixed TPM-SHA1 register has a valid attestation" $
      registerTestFromFile
        "tests/responses/attestation/tpm-es256-01.json"
        "https://localhost:44329"
        "localhost"
        False -- Uses a fake certificate in the chain
        registry
        predeterminedDateTime

-- | Checks if the received attestation response if one we expect
isExpectedAttestationResponse :: M.Credential 'M.Registration 'True -> M.CredentialOptions 'M.Registration -> Bool -> Either (NonEmpty O.RegistrationError) O.RegistrationResult -> Bool
isExpectedAttestationResponse _ _ _ (Left _) = False -- We should never receive errors
isExpectedAttestationResponse M.Credential {..} M.CredentialOptionsRegistration {..} verifiable (Right O.RegistrationResult {..}) =
  rrEntry == expectedCredentialEntry
    && not verifiable
      || ( case rrAttestationStatement of
             O.SomeAttestationStatement _ O.VerifiedAuthenticator {} -> True
             _ -> False
         )
  where
    expectedCredentialEntry :: O.CredentialEntry
    expectedCredentialEntry =
      O.CredentialEntry
        { ceCredentialId = cIdentifier,
          ceUserHandle = M.cueId corUser,
          cePublicKeyBytes =
            M.PublicKeyBytes . M.unRaw
              . M.acdCredentialPublicKeyBytes
              . M.adAttestedCredentialData
              . M.aoAuthData
              $ M.arrAttestationObject cResponse,
          ceSignCounter = M.adSignCount . M.aoAuthData $ M.arrAttestationObject cResponse,
          ceTransports = M.arrTransports cResponse
        }

defaultPublicKeyCredentialCreationOptions :: M.Credential 'M.Registration raw -> M.CredentialOptions 'M.Registration
defaultPublicKeyCredentialCreationOptions c =
  M.CredentialOptionsRegistration
    { M.corRp =
        M.CredentialRpEntity
          { M.creId = Nothing,
            M.creName = "Tweag I/O Test Server"
          },
      M.corUser =
        M.CredentialUserEntity
          { M.cueId = M.UserHandle "UserId",
            M.cueDisplayName = "UserDisplayName",
            M.cueName = "UserAccountName"
          },
      M.corChallenge = M.ccdChallenge . M.arrClientData $ M.cResponse c,
      M.corPubKeyCredParams =
        [ M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmES256
            },
          M.CredentialParameters
            { M.cpTyp = M.CredentialTypePublicKey,
              M.cpAlg = Cose.CoseAlgorithmRS256
            },
          M.CredentialParameters
            { cpTyp = M.CredentialTypePublicKey,
              cpAlg = Cose.CoseAlgorithmEdDSA
            }
        ],
      M.corTimeout = Nothing,
      M.corExcludeCredentials = [],
      M.corAuthenticatorSelection = Nothing,
      M.corAttestation = M.AttestationConveyancePreferenceNone,
      M.corExtensions = Nothing
    }

defaultPublicKeyCredentialRequestOptions :: M.Credential 'M.Authentication raw -> M.CredentialOptions 'M.Authentication
defaultPublicKeyCredentialRequestOptions c =
  M.CredentialOptionsAuthentication
    { M.coaChallenge = M.ccdChallenge . M.araClientData $ M.cResponse c,
      M.coaTimeout = Nothing,
      M.coaRpId = Just "localhost",
      M.coaAllowCredentials = [],
      M.coaUserVerification = M.UserVerificationRequirementPreferred,
      M.coaExtensions = Nothing
    }