packages feed

amazonka-core-2.0: src/Amazonka/Sign/V4/Base.hs

-- |
-- Module      : Amazonka.Sign.V4.Base
-- Copyright   : (c) 2013-2023 Brendan Hay
-- License     : Mozilla Public License, v. 2.0.
-- Maintainer  : Brendan Hay <brendan.g.hay+amazonka@gmail.com>
-- Stability   : provisional
-- Portability : non-portable (GHC extensions)
module Amazonka.Sign.V4.Base where

import qualified Amazonka.Bytes as Bytes
import Amazonka.Core.Lens.Internal ((<>~), (^.))
import qualified Amazonka.Crypto as Crypto
import Amazonka.Data hiding (Path)
import Amazonka.Prelude
import Amazonka.Request
import Amazonka.Types
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as BSB
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BSL
import qualified Data.CaseInsensitive as CI
import qualified Data.Foldable as Foldable
import qualified Data.Map.Strict as Map
import qualified Network.HTTP.Client as Client
import qualified Network.HTTP.Types as HTTP

data V4 = V4
  { metaTime :: UTCTime,
    metaMethod :: Method,
    metaPath :: Path,
    metaEndpoint :: Endpoint,
    metaCredential :: Credential,
    metaCanonicalQuery :: CanonicalQuery,
    metaCanonicalRequest :: CanonicalRequest,
    metaCanonicalHeaders :: CanonicalHeaders,
    metaSignedHeaders :: SignedHeaders,
    metaStringToSign :: StringToSign,
    metaSignature :: Signature,
    metaHeaders :: [Header],
    metaTimeout :: Maybe Seconds
  }

instance ToLog V4 where
  build V4 {metaEndpoint = Endpoint {host}, ..} =
    buildLines
      [ "[Version 4 Metadata] {",
        "  time              = " <> build metaTime,
        "  endpoint          = " <> build host,
        "  credential        = " <> build metaCredential,
        "  signed headers    = " <> build metaSignedHeaders,
        "  signature         = " <> build metaSignature,
        "  string to sign    = {",
        build metaStringToSign,
        "  }",
        "  canonical request = {",
        build metaCanonicalRequest,
        "  }",
        "}"
      ]

base ::
  Hash ->
  Request a ->
  AuthEnv ->
  Region ->
  UTCTime ->
  (V4, ClientRequest -> ClientRequest)
base h rq a region ts = (meta, auth)
  where
    auth = clientRequestHeaders <>~ [(HTTP.hAuthorization, authorisation meta)]

    meta = signMetadata a region ts presigner h (prepare rq)

    presigner _ _ = id

    prepare :: Request a -> Request a
    prepare r@Request {headers} =
      r
        { headers =
            headers
              & hdr hHost realHost
              & hdr hAMZDate (toBS (Time ts :: AWSTime))
              & hdr hAMZContentSHA256 (toBS h)
              & maybe id (hdr hAMZToken . toBS) (sessionToken a)
        }

    realHost =
      case (secure, port) of
        (False, 80) -> host
        (True, 443) -> host
        _ -> mconcat [host, ":", toBS port]

    Endpoint {host, port, secure} = endpoint (service rq) region

-- | Used to tag provenance. This allows keeping the same layout as
-- the signing documentation, passing 'ByteString's everywhere, with
-- some type guarantees.
--
-- Data.Tagged is not used for no reason other than the dependency, syntactic length,
-- and the ToByteString instance.
newtype Tag (s :: Symbol) a = Tag {untag :: a}
  deriving stock (Show)

instance ToByteString (Tag s ByteString) where toBS = untag

instance ToLog (Tag s ByteString) where build = build . untag

instance ToByteString CredentialScope where
  toBS = BS8.intercalate "/" . untag

type Hash = Tag "body-digest" ByteString

type StringToSign = Tag "string-to-sign" ByteString

type Credential = Tag "credential" ByteString

type CredentialScope = Tag "credential-scope" [ByteString]

type CanonicalRequest = Tag "canonical-request" ByteString

type CanonicalHeaders = Tag "canonical-headers" ByteString

type CanonicalQuery = Tag "canonical-query" ByteString

type SignedHeaders = Tag "signed-headers" ByteString

type NormalisedHeaders = Tag "normalised-headers" [(ByteString, ByteString)]

type Method = Tag "method" ByteString

type CanonicalPath = Tag "canonical-path" ByteString

type Path = Tag "path" ByteString

type Signature = Tag "signature" ByteString

authorisation :: V4 -> ByteString
authorisation V4 {..} =
  mconcat
    [ algorithm,
      " Credential=",
      toBS metaCredential,
      ", SignedHeaders=",
      toBS metaSignedHeaders,
      ", Signature=",
      toBS metaSignature
    ]

signRequest ::
  -- | Pre-signRequestd signing metadata.
  V4 ->
  -- | The request body.
  Client.RequestBody ->
  -- | Insert authentication information.
  (ClientRequest -> ClientRequest) ->
  Signed a
signRequest m@V4 {..} b auth = Signed (Meta m) (auth rq)
  where
    rq =
      (newClientRequest metaEndpoint metaTimeout)
        { Client.method = toBS metaMethod,
          Client.path = toBS metaPath,
          Client.queryString = qry,
          Client.requestHeaders = metaHeaders,
          Client.requestBody = b
        }

    qry
      | BS.null x = x
      | otherwise = '?' `BS8.cons` x
      where
        x = toBS metaCanonicalQuery

signMetadata ::
  AuthEnv ->
  Region ->
  UTCTime ->
  (Credential -> SignedHeaders -> QueryString -> QueryString) ->
  Hash ->
  Request a ->
  V4
signMetadata a r ts presign digest rq@Request {headers, method, query, service} =
  V4
    { metaTime = ts,
      metaMethod = method',
      metaPath = path,
      metaEndpoint = end,
      metaCredential = cred,
      metaCanonicalQuery = query',
      metaCanonicalRequest = crq,
      metaCanonicalHeaders = chs,
      metaSignedHeaders = shs,
      metaStringToSign = sts,
      metaSignature = signature (secretAccessKey a ^. _Sensitive) scope sts,
      metaHeaders = headers,
      metaTimeout = timeout service
    }
  where
    query' = canonicalQuery $ presign cred shs query

    sts = stringToSign ts scope crq
    cred = credential (accessKeyId a) scope
    scope = credentialScope service end ts
    crq = canonicalRequest method' cpath digest query' chs shs

    chs = canonicalHeaders normalisedHeaders
    shs = signedHeaders normalisedHeaders
    normalisedHeaders = normaliseHeaders headers

    end = endpoint service r
    method' = Tag $ toBS method
    path = escapedPath r rq
    cpath = canonicalPath r rq

algorithm :: ByteString
algorithm = "AWS4-HMAC-SHA256"

signature :: SecretKey -> CredentialScope -> StringToSign -> Signature
signature k c = Tag . Bytes.encodeBase16 . Crypto.hmacSHA256 signingKey . untag
  where
    signingKey = Foldable.foldl' hmac ("AWS4" <> toBS k) (untag c)

    hmac x y = Bytes.convert (Crypto.hmacSHA256 x y)

stringToSign :: UTCTime -> CredentialScope -> CanonicalRequest -> StringToSign
stringToSign t c r =
  Tag $
    BS8.intercalate
      "\n"
      [ algorithm,
        toBS (Time t :: AWSTime),
        toBS c,
        Bytes.encodeBase16 . Crypto.hashSHA256 $ toBS r
      ]

credential :: AccessKey -> CredentialScope -> Credential
credential k c = Tag (toBS k <> "/" <> toBS c)

credentialScope :: Service -> Endpoint -> UTCTime -> CredentialScope
credentialScope Service {signingName} Endpoint {scope} t =
  Tag
    [ toBS (Time t :: BasicTime),
      toBS scope,
      toBS signingName,
      "aws4_request"
    ]

canonicalRequest ::
  Method ->
  CanonicalPath ->
  Hash ->
  CanonicalQuery ->
  CanonicalHeaders ->
  SignedHeaders ->
  CanonicalRequest
canonicalRequest meth path digest query chs shs =
  Tag $
    BS8.intercalate
      "\n"
      [ toBS meth,
        toBS path,
        toBS query,
        toBS chs,
        toBS shs,
        toBS digest
      ]

escapedPath :: Region -> Request a -> Path
escapedPath r rq@Request {service = Service {abbrev}} =
  Tag $ case abbrev of
    "S3" -> toBS $ escapePath p
    _ -> toBS $ escapePath $ collapsePath p
  where
    p = fullRawPath r rq

canonicalPath :: Region -> Request a -> CanonicalPath
canonicalPath r rq@Request {service = Service {abbrev}} =
  Tag $ case abbrev of
    "S3" -> toBS $ escapePath p
    _ -> toBS $ escapePathTwice $ collapsePath p
  where
    p = fullRawPath r rq

-- | The complete raw path for a request, including any 'basePath' on
-- the endpoint.
fullRawPath :: Region -> Request a -> RawPath
fullRawPath r Request {path, service = Service {endpoint}} =
  basePath (endpoint r) <> path

canonicalQuery :: QueryString -> CanonicalQuery
canonicalQuery = Tag . toBS

-- FIXME: the following use of stripBS is too naive, should remove
-- all internal whitespace, replacing with a single space char,
-- unless quoted with \"...\"
canonicalHeaders :: NormalisedHeaders -> CanonicalHeaders
canonicalHeaders = Tag . BSL.toStrict . BSB.toLazyByteString . Foldable.foldMap (uncurry f) . untag
  where
    f k v = BSB.byteString k <> BSB.char7 ':' <> BSB.byteString (stripBS v) <> BSB.char7 '\n'

signedHeaders :: NormalisedHeaders -> SignedHeaders
signedHeaders = Tag . BS8.intercalate ";" . map fst . untag

normaliseHeaders :: [Header] -> NormalisedHeaders
normaliseHeaders =
  Tag
    . map (first CI.foldedCase)
    . Map.toAscList
    . Map.delete "authorization"
    . Map.delete "content-length"
    . Map.fromListWith const