packages feed

shomei-webauthn-0.3.0.0: test/Shomei/WebAuthn/SoftwareAuthenticator.hs

-- | A deliberately tiny test-only ES256 authenticator. The public private scalar (1)
-- and fixed signing nonce (2) MUST NEVER be used for real credentials. Building browser
-- wire data independently exercises production JSON/CBOR decoding as well as verification.
module Shomei.WebAuthn.SoftwareAuthenticator
  ( ResponsePolicy (..),
    goodPolicy,
    credentialBytes,
    fixtureHandle,
    registrationResponse,
    assertionResponse,
    challengeOf,
    freezeChallenge,
  )
where

import Crypto.Hash (Digest, SHA256 (..), hash)
import Crypto.Number.Serialize (i2osp, i2ospOf_)
import Crypto.PubKey.ECC.ECDSA qualified as ECDSA
import Crypto.PubKey.ECC.Prim (pointBaseMul)
import Crypto.PubKey.ECC.Types (CurveName (SEC_p256r1), Point (..), getCurveByName)
import Data.Aeson (Value (..), encode, object, (.=))
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (parseMaybe, withObject, (.:))
import Data.Bits (shiftR)
import Data.ByteArray (convert)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8)
import Data.Word (Word32, Word8)
import Shomei.Passkey.Domain (b64urlEncode)

data ResponsePolicy = ResponsePolicy
  { responseOrigin :: Text,
    responseRp :: Text,
    responseChallenge :: Maybe Text,
    responseType :: Maybe Text,
    responseFlags :: Word8,
    responseCounter :: Word32,
    responseHandle :: ByteString,
    corruptSignature :: Bool
  }

goodPolicy :: ResponsePolicy
goodPolicy = ResponsePolicy "https://localhost" "localhost" Nothing Nothing 5 1 fixtureHandle False

credentialBytes, fixtureHandle :: ByteString
credentialBytes = "test-only-es256-credential"
fixtureHandle = "test-only-user-handle"

challengeOf :: Value -> Text
challengeOf v = maybe (error "fixture options lack challenge") id (parseMaybe (withObject "options" (.: "challenge")) v)

freezeChallenge :: Value -> Value
freezeChallenge (Object o) = Object (KM.insert "challenge" (String (b64urlEncode "fixed-test-challenge-with-32-bytes")) o)
freezeChallenge _ = error "fixture options are not an object"

sha256 :: ByteString -> ByteString
sha256 bytes = convert (hash bytes :: Digest SHA256)

-- Minimal definite-length CBOR for this fixture's byte and text strings.
cbor :: Word8 -> ByteString -> ByteString
cbor major bytes = prefix <> bytes
  where
    n = BS.length bytes
    prefix
      | n < 24 = BS.singleton (major + fromIntegral n)
      | n < 256 = BS.pack [major + 24, fromIntegral n]
      | otherwise = BS.pack [major + 25, fromIntegral (n `shiftR` 8), fromIntegral n]

publicKey :: ByteString
publicKey = case pointBaseMul (getCurveByName SEC_p256r1) 1 of
  Point x y -> BS.pack [0xa5, 1, 2, 3, 0x26, 0x20, 1, 0x21] <> cbor 0x40 (i2ospOf_ 32 x) <> BS.singleton 0x22 <> cbor 0x40 (i2ospOf_ 32 y)
  PointO -> error "invalid fixture point"

authData :: Bool -> ResponsePolicy -> ByteString
authData registration p = sha256 (encodeUtf8 p.responseRp) <> BS.singleton flags <> counter <> attested
  where
    flags = p.responseFlags + if registration then 0x40 else 0
    n = p.responseCounter
    counter = BS.pack [fromIntegral (n `shiftR` bits) | bits <- [24, 16, 8, 0]]
    attested
      | registration = BS.replicate 16 0 <> BS.pack [0, fromIntegral (BS.length credentialBytes)] <> credentialBytes <> publicKey
      | otherwise = BS.empty

clientData :: Text -> Value -> ResponsePolicy -> ByteString
clientData kind options p =
  LBS.toStrict
    ( encode
        ( object
            [ "type" .= maybe kind id p.responseType,
              "challenge" .= maybe (challengeOf options) id p.responseChallenge,
              "origin" .= p.responseOrigin,
              "crossOrigin" .= False
            ]
        )
    )

registrationResponse :: Value -> ResponsePolicy -> Value
registrationResponse options p =
  object
    [ "id" .= b64urlEncode credentialBytes,
      "rawId" .= b64urlEncode credentialBytes,
      "type" .= ("public-key" :: Text),
      "response"
        .= object
          [ "clientDataJSON" .= b64urlEncode (clientData "webauthn.create" options p),
            "attestationObject" .= b64urlEncode attestation,
            "transports" .= ([] :: [Text])
          ],
      "clientExtensionResults" .= object []
    ]
  where
    attestation = BS.singleton 0xa3 <> cbor 0x60 "fmt" <> cbor 0x60 "none" <> cbor 0x60 "attStmt" <> BS.singleton 0xa0 <> cbor 0x60 "authData" <> cbor 0x40 (authData True p)

assertionResponse :: Value -> ResponsePolicy -> Value
assertionResponse options p =
  object
    [ "id" .= b64urlEncode credentialBytes,
      "rawId" .= b64urlEncode credentialBytes,
      "type" .= ("public-key" :: Text),
      "response"
        .= object
          [ "clientDataJSON" .= b64urlEncode client,
            "authenticatorData" .= b64urlEncode authenticator,
            "signature" .= b64urlEncode signature,
            "userHandle" .= b64urlEncode p.responseHandle
          ],
      "clientExtensionResults" .= object []
    ]
  where
    client = clientData "webauthn.get" options p
    authenticator = authData False p
    signature
      | p.corruptSignature = "invalid-signature"
      | otherwise = case ECDSA.signWith 2 (ECDSA.PrivateKey (getCurveByName SEC_p256r1) 1) SHA256 (authenticator <> sha256 client) of
          Just (ECDSA.Signature r s) -> der 0x30 (integer r <> integer s)
          Nothing -> error "fixture signature failed"
    integer n = let bytes = i2osp n; positive = if BS.head bytes >= 128 then BS.cons 0 bytes else bytes in der 2 positive
    der tag bytes = BS.pack [tag, fromIntegral (BS.length bytes)] <> bytes