packages feed

webauthn-0.1.0.0: src/Crypto/WebAuthn/AttestationStatementFormat/AndroidSafetyNet.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}

-- | Stability: experimental
-- This module implements the
-- [Android SafetyNet Attestation Statement Format](https://www.w3.org/TR/webauthn-2/#sctn-android-safetynet-attestation).
module Crypto.WebAuthn.AttestationStatementFormat.AndroidSafetyNet
  ( format,
    Format (..),
    Integrity (..),
    VerificationError (..),
  )
where

import Codec.CBOR.Term (Term (TBytes, TString))
import qualified Codec.CBOR.Term as CBOR
import Control.Lens ((^.), (^?))
import Control.Lens.Combinators (_Just)
import Control.Monad (unless, when)
import Control.Monad.Except (MonadError, runExcept, throwError)
import qualified Crypto.Hash as Hash
import qualified Crypto.JOSE as JOSE
import qualified Crypto.JWT as JOSE
import Crypto.WebAuthn.Internal.DateOrphans ()
import Crypto.WebAuthn.Internal.Utils (failure)
import qualified Crypto.WebAuthn.Model.Types as M
import qualified Data.ASN1.Types.String as X509
import Data.Aeson ((.=))
import qualified Data.Aeson as Aeson
import Data.Bifunctor (Bifunctor (first))
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Lazy as LBS
import Data.Fixed (Fixed (MkFixed), Milli)
import Data.HashMap.Lazy ((!?))
import qualified Data.Hourglass as HG
import qualified Data.List.NonEmpty as NE
import Data.String (IsString)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Text.Encoding (decodeUtf8)
import qualified Data.X509 as X509
import qualified Data.X509.Validation as X509
import GHC.Exception (Exception)
import GHC.Generics (Generic)

-- | [(spec)](https://developer.android.com/training/safetynet/attestation#potential-integrity-verdicts)
-- The integrity of an android device from which a SafetyNet message
-- originated.
data Integrity
  = -- | The device has no integrity, which is the case for an emulator, or it
    -- could be the case for a compromised device
    NoIntegrity
  | -- | The device must have passed the basic integrity check, which is e.g.
    -- the case for a device with a custom ROM but not rooted, or a certified
    -- device with an unlocked bootloader
    BasicIntegrity
  | -- | The device passed the [CTS](https://source.android.com/compatibility/cts/),
    -- it is genuine and verified
    CTSProfileIntegrity
  deriving (Enum, Bounded, Eq, Ord, Show)

-- | The Android SafetyKey Format. Allows configuration of the required level of
-- trust.
data Format = Format
  { -- | What level the integrity check of the originating Android device must
    -- have passed.
    requiredIntegrity :: Integrity,
    -- | The maximum time the received message may be old for it to still be
    -- considered valid.
    driftBackwardsTolerance :: HG.Duration,
    -- | The maximum time difference the received message may report being from
    -- the future for it to still be considered valid.
    driftForwardsTolerance :: HG.Duration
  }

instance Show Format where
  show = Text.unpack . M.asfIdentifier

-- | [(spec)](https://developer.android.com/training/safetynet/attestation.html#compat-check-response)
data Response = Response
  { timestampMs :: Milliseconds,
    nonce :: Text,
    apkPackageName :: Text,
    apkCertificateDigestSha256 :: [Text], -- [Base 64 encoded SHA256 hash]
    ctsProfileMatch :: Bool,
    basicIntegrity :: Bool,
    evaluationType :: Text
  }
  deriving (Eq, Show, Generic, Aeson.FromJSON, Aeson.ToJSON)

-- | Milliseconds represented as an 'Integer', used for 'timestampMs'
newtype Milliseconds = Milliseconds Integer
  deriving (Eq, Show)
  deriving newtype (Aeson.FromJSON, Aeson.ToJSON)
  deriving (HG.Timeable) via Milli

-- | [(spec)](https://www.w3.org/TR/webauthn-2/#sctn-android-safetynet-attestation)
data Statement = Statement
  { ver :: Text.Text,
    x5c :: NE.NonEmpty X509.SignedCertificate,
    response :: Response,
    responseRaw :: BS.ByteString
  }
  deriving (Eq, Show)

instance Aeson.ToJSON Statement where
  toJSON Statement {..} =
    Aeson.object
      [ "ver" .= ver,
        "x5c" .= x5c,
        "response" .= response
      ]

-- | Verification errors specific to Android SafetyNet
data VerificationError
  = -- | The receiced nonce was not set to the concatenation of the
    -- authenticator data and client data hash
    VerificationErrorInvalidNonce
  | -- | The response was created to far in the past
    -- (first: now, second: generated time)
    VerificationErrorResponseTooOld HG.DateTime HG.DateTime
  | -- | The response was created to far in the future
    -- (first: now, second: generated time)
    VerificationErrorResponseInFuture HG.DateTime HG.DateTime
  | -- | The integrity check failed based on the required integrity from the
    -- format
    VerificationErrorFailedIntegrityCheck Integrity
  deriving (Show, Exception)

androidHostName :: VerificationHostName
androidHostName = "attest.android.com"

newtype VerificationHostName = VerificationHostName {unVerificationHostName :: X509.HostName}
  deriving newtype (IsString)

-- | This instance doesn't actually perform any validation
instance MonadError JOSE.Error m => JOSE.VerificationKeyStore m (JOSE.JWSHeader ()) p VerificationHostName where
  getVerificationKeys header _ hostName = do
    chain <- case header ^? JOSE.x5c . _Just . JOSE.param of
      Nothing -> throwError JOSE.JWSInvalidSignature
      Just chain -> pure chain

    let leaf = NE.head chain
    case X509.asn1CharacterToString
      =<< ( X509.getDnElement X509.DnCommonName
              . X509.certSubjectDN
              . X509.getCertificate
              $ leaf
          ) of
      Nothing -> pure ()
      Just commonName ->
        unless (commonName == unVerificationHostName hostName)
          . throwError
          $ JOSE.JWSInvalidSignature

    -- Create a JWK from the leaf certificate, which is used to sign the payload
    pure <$> JOSE.fromX509Certificate leaf

instance M.AttestationStatementFormat Format where
  type AttStmt Format = Statement
  asfIdentifier _ = "android-safetynet"

  asfDecode _ xs =
    case (xs !? "ver", xs !? "response") of
      (Just (TString ver), Just (TBytes responseRaw)) -> do
        jws <-
          first (("Failed to decode compact JWT response blob: " <>) . Text.pack . show) $
            runExcept @JOSE.Error $ JOSE.decodeCompact (LBS.fromStrict responseRaw)
        response <-
          first (("Failed to verify/decode JWT payload: " <>) . Text.pack . show) $
            JOSE.verifyJWSWithPayload
              (first JOSE.JSONDecodeError . Aeson.eitherDecode)
              (JOSE.defaultJWTValidationSettings (const True))
              androidHostName
              jws
        x5c <- extractX5C jws
        pure $ Statement {..}
      _ -> Left $ "CBOR map didn't have expected types (ver: string, response: bytes): " <> Text.pack (show xs)
    where
      extractX5C :: JOSE.CompactJWS JOSE.JWSHeader -> Either Text (NE.NonEmpty X509.SignedCertificate)
      extractX5C jws = do
        sig <- case jws ^? JOSE.signatures of
          Nothing -> Left "Can't extract x5c because the JWT contains no signatures"
          Just res -> pure res
        JOSE.HeaderParam () x5c <- case sig ^. JOSE.header . JOSE.x5c of
          Nothing -> Left "No x5c in the header of the first JWT signature"
          Just res -> pure res
        pure x5c

  asfEncode _ Statement {..} =
    CBOR.TMap
      [ (TString "ver", TString ver),
        (TString "response", TBytes responseRaw)
      ]

  type AttStmtVerificationError Format = VerificationError
  asfVerify Format {..} now Statement {..} M.AuthenticatorData {adRawData = M.WithRaw rawData} clientDataHash = do
    -- 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to
    -- extract the contained fields.
    -- NOTE: Done in decoding

    -- 2. Verify that response is a valid SafetyNet response of version ver by following the steps indicated by the
    -- SafetyNet online documentation. As of this writing, there is only one format of the SafetyNet response and ver
    -- is reserved for future use.
    -- NOTE: As stated above, only one version exists, which we assume during decoding

    -- 3. Verify that the nonce attribute in the payload of response is identical to the Base64 encoding of the SHA-256
    -- hash of the concatenation of authenticatorData and clientDataHash.
    let signedData = rawData <> BA.convert (M.unClientDataHash clientDataHash)
    let hashedData = Hash.hashWith Hash.SHA256 signedData
    let encodedData = decodeUtf8 . Base64.encode $ BA.convert hashedData
    unless (nonce response == encodedData) . failure $ VerificationErrorInvalidNonce

    -- 4. Verify that the SafetyNet response actually came from the SafetyNet service by following the steps in the
    -- SafetyNet online documentation.
    -- 4.1. Extract the SSL certificate chain from the JWS message.
    -- NOTE: Done during decoding

    -- 4.2. Validate the SSL certificate chain and use SSL hostname matching to verify that the leaf certificate was
    -- issued to the hostname attest.android.com.
    -- NOTE: Done during decoding

    -- 4.3. Use the certificate to verify the signature of the JWS message.
    -- NOTE: Done during decoding. The jose library forces us to do verification before we access the payload. Since we
    -- would like to decode the payload during decoding, this step is also done during decoding.

    -- 4.4. Check the data of the JWS message to make sure it matches the data within your original request. In particular,
    -- make sure that the timestamp has been validated and that the nonce, package name, and hashes of the app's
    -- signing certificate(s) match the expected values.
    -- NOTE: For WebAuthn, we need not care about the package name or the app's signing certificate. The Nonce as
    -- has already been dealt with.
    let generatedTime = HG.timeConvert $ timestampMs response
    when ((generatedTime `HG.timeAdd` driftBackwardsTolerance) < now) $ failure $ VerificationErrorResponseTooOld now generatedTime
    when (generatedTime > (now `HG.timeAdd` driftForwardsTolerance)) $ failure $ VerificationErrorResponseInFuture now generatedTime

    let integrity = case (basicIntegrity response, ctsProfileMatch response) of
          (_, True) -> CTSProfileIntegrity
          (True, False) -> BasicIntegrity
          (False, False) -> NoIntegrity
    unless (integrity >= requiredIntegrity) $
      failure $ VerificationErrorFailedIntegrityCheck integrity

    -- 5. If successful, return implementation-specific values representing attestation type Basic and attestation trust
    -- path x5c.
    pure $
      M.SomeAttestationType $
        M.AttestationTypeVerifiable M.VerifiableAttestationTypeBasic (M.Fido2Chain x5c)

  asfTrustAnchors _ _ = mempty

-- | The default SafetyNet format configuration. Requires full
-- CTSProfileIntegrity and allows for the SafetyNet message to be at most 60
-- seconds old. Does not allow any timedrift into the future.
format :: M.SomeAttestationStatementFormat
format =
  M.SomeAttestationStatementFormat $
    Format
      { requiredIntegrity = CTSProfileIntegrity,
        driftBackwardsTolerance = mempty {HG.durationSeconds = 60},
        driftForwardsTolerance = mempty
      }