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
}