packages feed

amazonka-core-2.0: src/Amazonka/Sign/V2Header.hs

-- |
-- Module      : Amazonka.Sign.V2Header
-- Stability   : provisional
-- Portability : non-portable (GHC extensions)
--
-- This module provides an AWS compliant V2 Header request signer. It is based
-- heavily on <https://github.com/boto/boto boto>, specifically boto's
-- @HmacAuthV1Handler@ AWS capable signer. AWS documentation is available
-- <http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html here>.
--
-- Notice: Limitations include an inability to sign with a security token and
-- inability to overwrite the @Date@ header with an expiry.
module Amazonka.Sign.V2Header
  ( v2Header,
    newSigner,
    toSignerQueryBS,
    constructSigningHeader,
    constructSigningQuery,
    constructFullPath,
    unionNecessaryHeaders,
  )
where

import qualified Amazonka.Bytes as Bytes
import qualified Amazonka.Crypto as Crypto
import Amazonka.Data
import qualified Amazonka.Data.Query as Query
import Amazonka.Prelude
import Amazonka.Types hiding (presign, sign)
import qualified Data.ByteString.Builder as Build
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import qualified Data.CaseInsensitive as CI
import qualified Data.Function as Function
import qualified Data.List as List
import qualified Network.HTTP.Client as Client
import qualified Network.HTTP.Types as HTTP
import qualified Network.HTTP.Types.URI as URI

data V2Header = V2Header
  { metaTime :: UTCTime,
    metaEndpoint :: Endpoint,
    metaSignature :: ByteString,
    headers :: HTTP.RequestHeaders,
    signer :: ByteString
  }

instance ToLog V2Header where
  build V2Header {..} =
    buildLines
      [ "[Version 2 Header Metadata] {",
        "  time      = " <> build metaTime,
        "  endpoint  = " <> build (host metaEndpoint),
        "  signature = " <> build metaSignature,
        "  headers = " <> build headers,
        "  signer = " <> build signer,
        "}"
      ]

v2Header :: Signer
v2Header = Signer sign (const sign)

sign :: Algorithm a
sign Request {..} AuthEnv {..} r t = Signed meta rq
  where
    meta = Meta (V2Header t end signature headers signer)

    signer = newSigner headers' meth path' query

    rq =
      (newClientRequest end timeout)
        { Client.method = meth,
          Client.path = path',
          Client.queryString = toBS query,
          Client.requestHeaders = headers',
          Client.requestBody = toRequestBody body
        }

    meth = toBS method
    path' = toBS (escapePath $ basePath <> path)

    end@Endpoint {basePath} = endpoint r

    Service {timeout, endpoint} = service

    signature =
      Bytes.encodeBase64
        . Crypto.hmacSHA1 (toBS secretAccessKey)
        $ signer

    headers' =
      headers
        & hdr HTTP.hAuthorization ("AWS " <> toBS accessKeyId <> ":" <> signature)
        & hdr HTTP.hDate time

    time = toBS (Time t :: RFC822)

-- | Construct a full header signer following the V2 Header scheme
newSigner ::
  HTTP.RequestHeaders ->
  ByteString ->
  ByteString ->
  Query.QueryString ->
  ByteString
newSigner headers method path query = signer
  where
    signer =
      BS8.intercalate
        "\n"
        ( method
            : map constructSigningHeader (List.sort filteredHeaders)
            ++ [constructFullPath path (toSignerQueryBS filteredQuery)]
        )

    filteredHeaders = unionNecessaryHeaders (filter isInterestingHeader headers)

    filteredQuery = constructSigningQuery query

-- | The following function mostly follows the toBS in amazonka QueryString
-- except for single QValue or single QPair keys not being suffixed with
-- an equals.
toSignerQueryBS :: Query.QueryString -> ByteString
toSignerQueryBS =
  LBS.toStrict . Build.toLazyByteString . cat . List.sort . enc Nothing
  where
    enc :: Maybe ByteString -> Query.QueryString -> [ByteString]
    enc p = \case
      Query.QList xs -> concatMap (enc p) xs
      Query.QPair (URI.urlEncode True -> k) x
        | Just n <- p -> enc (Just (n <> kdelim <> k)) x -- <prev>.key <recur>
        | otherwise -> enc (Just k) x -- key <recur>
      Query.QValue (Just (URI.urlEncode True -> v))
        | Just n <- p -> [n <> vsep <> v] -- key=value
        | otherwise -> [v]
      _
        | Just n <- p -> [n]
        | otherwise -> []

    cat :: [ByteString] -> ByteStringBuilder
    cat [] = mempty
    cat [x] = Build.byteString x
    cat (x : xs) = Build.byteString x <> ksep <> cat xs

    kdelim = "."
    ksep = "&"
    vsep = "="

hasAWSPrefix :: CI.CI ByteString -> Bool
hasAWSPrefix = BS8.isPrefixOf "aws-" . CI.foldedCase

-- | Filter for 'interesting' keys within a QueryString
isInterestingQueryKey :: ByteString -> Bool
isInterestingQueryKey = \case
  "acl" -> True
  "cors" -> True
  "defaultObjectAcl" -> True
  "location" -> True
  "logging" -> True
  "partNumber" -> True
  "policy" -> True
  "requestPayment" -> True
  "torrent" -> True
  "versioning" -> True
  "versionId" -> True
  "versions" -> True
  "website" -> True
  "uploads" -> True
  "uploadId" -> True
  "response-content-type" -> True
  "response-content-language" -> True
  "response-expires" -> True
  "response-cache-control" -> True
  "response-content-disposition" -> True
  "response-content-encoding" -> True
  "delete" -> True
  "lifecycle" -> True
  "tagging" -> True
  "restore" -> True
  "storageClass" -> True
  "websiteConfig" -> True
  "compose" -> True
  _ -> False

-- | Filter for 'interesting' header fields
isInterestingHeader :: HTTP.Header -> Bool
isInterestingHeader (name, _)
  | name == HTTP.hDate = True
  | name == HTTP.hContentMD5 = True
  | name == HTTP.hContentType = True
  | hasAWSPrefix name = True
  | otherwise = False

-- | Constructs a query string for signing
constructSigningQuery :: Query.QueryString -> Query.QueryString
constructSigningQuery = \case
  Query.QValue {} -> Query.QValue Nothing
  Query.QList qs -> Query.QList (map constructSigningQuery qs)
  Query.QPair k v
    | isInterestingQueryKey k -> Query.QPair k v
    | otherwise -> Query.QValue Nothing

-- | Construct a header string for signing
constructSigningHeader :: HTTP.Header -> ByteString
constructSigningHeader (name, value)
  | hasAWSPrefix name = CI.foldedCase name <> ":" <> value
  | otherwise = value

constructFullPath :: ByteString -> ByteString -> ByteString
constructFullPath path q
  | BS8.null q = path
  | otherwise = path <> "?" <> q

unionNecessaryHeaders :: [HTTP.Header] -> [HTTP.Header]
unionNecessaryHeaders =
  flip
    (List.unionBy (Function.on (==) fst))
    [ (HTTP.hContentMD5, ""),
      (HTTP.hContentType, "")
    ]