s3-signer 0.3.0.0 → 0.4.0.0
raw patch · 7 files changed
+299/−100 lines, 7 filesdep +blaze-builderdep +byteabledep +bytestringPVP ok
version bump matches the API change (PVP)
Dependencies added: blaze-builder, byteable, bytestring, case-insensitive, s3-signer
API changes (from Hackage documentation)
- Network.S3: S3Keys :: ByteString -> ByteString -> S3Keys
- Network.S3: S3URL :: ByteString -> S3URL
- Network.S3: bucketName :: S3Request -> ByteString
- Network.S3: data S3Keys
- Network.S3: mimeType :: S3Request -> ByteString
- Network.S3: newtype S3URL
- Network.S3: objectName :: S3Request -> ByteString
- Network.S3: publicKey :: S3Keys -> ByteString
- Network.S3: s3method :: S3Request -> S3Method
- Network.S3: secondsToExpire :: S3Request -> Integer
- Network.S3: secretKey :: S3Keys -> ByteString
- Network.S3: signedRequest :: S3URL -> ByteString
+ Network.S3: S3DELETE :: S3Method
+ Network.S3: S3HEAD :: S3Method
+ Network.S3: S3SignedRequest :: [S3Header] -> ByteString -> ByteString -> ByteString -> ByteString -> ByteString -> S3SignedRequest
+ Network.S3: [bucketName] :: S3Request -> ByteString
+ Network.S3: [mimeType] :: S3Request -> Maybe ByteString
+ Network.S3: [objectName] :: S3Request -> ByteString
+ Network.S3: [payloadHash] :: S3Request -> Maybe ByteString
+ Network.S3: [queryString] :: S3Request -> Query
+ Network.S3: [regionName] :: S3Request -> ByteString
+ Network.S3: [requestTime] :: S3Request -> UTCTime
+ Network.S3: [s3headers] :: S3Request -> [S3Header]
+ Network.S3: [s3method] :: S3Request -> S3Method
+ Network.S3: [sigAlgorithm] :: S3SignedRequest -> ByteString
+ Network.S3: [sigCredential] :: S3SignedRequest -> ByteString
+ Network.S3: [sigDate] :: S3SignedRequest -> ByteString
+ Network.S3: [sigHeaders] :: S3SignedRequest -> [S3Header]
+ Network.S3: [sigPolicy] :: S3SignedRequest -> ByteString
+ Network.S3: [sigSignature] :: S3SignedRequest -> ByteString
+ Network.S3: data S3Header
+ Network.S3: data S3SignedRequest
+ Network.S3: getS3Header :: S3Header -> (ByteString, ByteString)
+ Network.S3: s3Header :: ByteString -> ByteString -> S3Header
+ Network.S3: s3HeaderBuilder :: S3Header -> Builder
- Network.S3: S3Request :: S3Method -> ByteString -> ByteString -> ByteString -> Integer -> S3Request
+ Network.S3: S3Request :: S3Method -> Maybe ByteString -> ByteString -> ByteString -> ByteString -> Query -> UTCTime -> Maybe ByteString -> [S3Header] -> S3Request
- Network.S3: generateS3URL :: S3Keys -> S3Request -> IO S3URL
+ Network.S3: generateS3URL :: ByteString -> S3Request -> IO S3SignedRequest
Files
- README.md +8/−2
- s3-signer.cabal +45/−10
- src/Network/S3.hs +8/−16
- src/Network/S3/Sign.hs +67/−7
- src/Network/S3/Types.hs +59/−19
- src/Network/S3/URL.hs +56/−46
- test/Test.hs +56/−0
README.md view
@@ -1,10 +1,16 @@-s3-signer [](https://hackage.haskell.org/package/s3-signer)+s3-signer ======++++++ s3-signer is intended to be an aid in building secure cloud-based services with AWS. This library generates cryptographically secure URLs that expire at a user-defined interval. These URLs can be used to offload the process of uploading and downloading large files, freeing your-webserver to focus on other things. +webserver to focus on other things. ### Features - Minimal depedencies
s3-signer.cabal view
@@ -1,13 +1,13 @@ name: s3-signer-version: 0.3.0.0+version: 0.4.0.0 homepage: https://github.com/dmjio/s3-signer bug-reports: https://github.com/dmjio/s3-signer/issues synopsis: Pre-signed Amazon S3 URLs-description: +description: .- s3-signer creates cryptographically secure Amazon S3 URLs that expire within a user-defined - period. It allows uploading and downloading of content from Amazon S3. - Ideal for AJAX direct-to-s3 uploads via CORS and secure downloads. + s3-signer creates cryptographically secure Amazon S3 URLs that expire within a user-defined+ period. It allows uploading and downloading of content from Amazon S3.+ Ideal for AJAX direct-to-s3 uploads via CORS and secure downloads. Web framework agnostic with minimal dependencies. . > module Main where@@ -17,25 +17,49 @@ > where > credentials = S3Keys "<public-key-goes-here>" "<secret-key-goes-here>" > request = S3Request S3GET "application/extension" "bucket-name" "file-name.extension" 3 -- three seconds until expiration- . + . Result . > S3URL "https://bucket-name.s3.amazonaws.com/file-name.extension?AWSAccessKeyId=<public-key-goes-here>&Expires=1402346638&Signature=1XraY%2Bhp117I5CTKNKPc6%2BiihRA%3D" license: BSD3 license-file: LICENSE-author: David Johnson <djohnson.m@gmail.com>+author: David Johnson <djohnson.m@gmail.com>, William Casarin <jb55@jb55.com> maintainer: David Johnson <djohnson.m@gmail.com>-copyright: 2014 David Johnson+copyright: David Johnson (c) 2014-2018 category: AWS, Network build-type: Simple-cabal-version: >=1.18-extra-source-files: README.md +cabal-version: 2.0+extra-source-files: README.md+tested-with: GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.1 +library internal+ default-language: Haskell2010+ build-depends: base == 4.*+ , base64-bytestring+ , bytestring+ , byteable+ , utf8-string+ , case-insensitive >= 1.2+ , blaze-builder >= 0.4+ , http-types+ , cryptohash+ , time++ hs-source-dirs: src+ exposed-modules: Network.S3+ , Network.S3.Sign+ , Network.S3.URL+ , Network.S3.Types+ library build-depends: base == 4.* , base64-bytestring+ , bytestring+ , byteable , utf8-string+ , case-insensitive >= 1.2+ , blaze-builder >= 0.4 , http-types , cryptohash , time@@ -47,6 +71,17 @@ , Network.S3.Time , Network.S3.URL , Network.S3.Types+++test-suite test-simple+ default-language: Haskell2010+ type: exitcode-stdio-1.0+ main-is: test/Test.hs+ build-depends: internal+ , base == 4.*+ , bytestring+ , blaze-builder >= 0.4+ , time source-repository head
src/Network/S3.hs view
@@ -8,23 +8,15 @@ , module Network.S3.Types ) where +import Data.ByteString (ByteString)+import Data.Time.Clock (getCurrentTime) import Network.S3.Sign-import Network.S3.Time import Network.S3.Types-import Network.S3.URL --- | Generates a cryptographically secure URL that expires within a--- user defined period------ See README for use cases and examples: <https://github.com/dmjio/s3-signer/blob/master/README.md>-generateS3URL :: S3Keys -- ^ Amazon S3 Keys- -> S3Request -- ^ Amazon S3 Request information- -> IO S3URL -- ^ Generated URL-generateS3URL S3Keys{..} S3Request{..} = do- time <- getExpirationTimeStamp secondsToExpire- let url = case s3method of- S3GET -> getURL bucketName objectName time- S3PUT -> putURL bucketName objectName time mimeType - req = s3URL bucketName objectName publicKey time (sign secretKey url)- return (S3URL req) +generateS3URL :: ByteString -- ^ Amazon S3 SecretAccessKey+ -> S3Request -- ^ Amazon S3 Request information+ -> IO S3SignedRequest -- ^ Generated Request+generateS3URL secretKey req = do+ time <- getCurrentTime+ return (sign secretKey req time)
src/Network/S3/Sign.hs view
@@ -1,11 +1,71 @@+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+ module Network.S3.Sign ( sign ) where -import Crypto.Hash.SHA1 (hash)-import Crypto.MAC.HMAC (hmac)-import qualified Data.ByteString.Base64 as B64-import Data.ByteString.UTF8 (ByteString)-import Network.HTTP.Types.URI (urlEncode)+import qualified Data.ByteString.Char8 as BS+import qualified Data.ByteString.Base64 as Base64+import Data.ByteString.UTF8 (ByteString)+import Blaze.ByteString.Builder (toByteString, fromByteString)+import Data.Monoid ((<>))+import Data.Time.Clock (UTCTime)+import Data.Time.Format (formatTime, defaultTimeLocale)+import Network.S3.Types (S3Request(..), S3SignedRequest(..))+import Network.S3.URL (canonicalRequest)+import Data.Byteable (toBytes)+import Crypto.Hash ++reqString :: S3Request -> Digest SHA256+reqString = hash . toByteString . canonicalRequest++hmacSHA256 :: ByteString -> ByteString -> HMAC SHA256+hmacSHA256 key = hmac key++hmac_ :: ByteString -> ByteString -> ByteString+hmac_ key = toBytes . hmacGetDigest . hmacSHA256 key++ -- | SHA1 Encrypted Signature-sign :: ByteString -> ByteString -> ByteString-sign secretKey url = urlEncode True . B64.encode $ hmac hash 64 secretKey url+sign :: ByteString -> S3Request -> UTCTime -> S3SignedRequest+sign key req utc =+ let+ seconds = BS.pack (formatTime defaultTimeLocale "T%M%H%SZ" utc)+ date = BS.pack (formatTime defaultTimeLocale "%Y%m%d" utc)++ timestamp = fromByteString (date <> seconds)+ reqHash = fromByteString (digestToHexByteString (reqString req))+ region = regionName req+ service = "s3"++ dateKey = hmac_ ("AWS4" <> key) date+ dateRegionKey = hmac_ dateKey region+ dateRegionServiceKey = hmac_ dateRegionKey service+ signingKey = hmac_ dateRegionServiceKey "aws4_request"++ scope =+ fromByteString date <> fromByteString "/" <>+ fromByteString region <> fromByteString "/" <>+ fromByteString service <> fromByteString "/aws4_request"++ algorithm = "AWS4-HMAC-SHA256"++ signingStringBuilder =+ algorithm <> "\n" <>+ timestamp <> "\n" <>+ scope <> "\n" <>+ reqHash++ stringToSign = toByteString signingStringBuilder+ signature = hmacSHA256 signingKey stringToSign+ hexSignature = digestToHexByteString (hmacGetDigest signature)+ in+ S3SignedRequest {+ sigHeaders = s3headers req+ , sigDate = toByteString timestamp+ , sigCredential = toByteString scope+ , sigPolicy = Base64.encode stringToSign+ , sigSignature = hexSignature+ , sigAlgorithm = toByteString algorithm+ }
src/Network/S3/Types.hs view
@@ -1,32 +1,72 @@ {-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedStrings #-} module Network.S3.Types- ( S3URL (..)- , S3Keys (..)- , S3Method (..)+ ( S3Method (..) , S3Request (..)+ , S3SignedRequest (..)+ , S3Header+ , getS3Header+ , s3Header+ , s3HeaderBuilder ) where -import Data.ByteString.UTF8 (ByteString)-import GHC.Generics (Generic)+import Data.ByteString.UTF8 (ByteString)+import GHC.Generics (Generic)+import Data.Char (isSpace)+import Network.HTTP.Types (Query)+import Data.Time.Clock (UTCTime) -newtype S3URL = S3URL {- signedRequest :: ByteString -- ^ Generated URL- } deriving (Generic, Show)+import Blaze.ByteString.Builder (Builder, fromByteString)+import Data.Monoid (mconcat)+import qualified Data.ByteString.Char8 as BS+import qualified Data.CaseInsensitive as CI -data S3Keys = S3Keys {- publicKey :: ByteString -- ^ AWS Public Key- , secretKey :: ByteString -- ^ AWS Private Key- } deriving (Generic, Show) -data S3Method = S3GET -- ^ GET Request- | S3PUT -- ^ PUT Request+newtype S3Header = S3Header { getS3Header :: (ByteString, ByteString) }+ deriving (Generic, Show)+++data S3Method = S3GET -- ^ GET Request+ | S3PUT -- ^ PUT Request+ | S3HEAD -- ^ HEAD Request+ | S3DELETE -- ^ DELETE Request deriving (Generic, Show) + data S3Request = S3Request {- s3method :: S3Method -- ^ Type of HTTP Method- , mimeType :: ByteString -- ^ MIME Type- , bucketName :: ByteString -- ^ Name of Amazon S3 Bucket- , objectName :: ByteString -- ^ Name of Amazon S3 File- , secondsToExpire :: Integer -- ^ Number of seconds until expiration+ s3method :: S3Method -- ^ Type of HTTP Method+ , mimeType :: Maybe ByteString -- ^ MIME Type+ , bucketName :: ByteString -- ^ Name of Amazon S3 Bucket+ , objectName :: ByteString -- ^ Name of Amazon S3 File+ , regionName :: ByteString -- ^ Name of Amazon S3 Region+ , queryString :: Query -- ^ Optional query string items+ , requestTime :: UTCTime -- ^ Requests are valid within a 15 minute window of this timestamp+ , payloadHash :: Maybe ByteString -- ^ SHA256 hash string of the payload+ , s3headers :: [S3Header] -- ^ Headers } deriving (Generic, Show)+++data S3SignedRequest = S3SignedRequest {+ sigHeaders :: [S3Header] -- ^ The headers included in the signed request+ , sigDate :: ByteString -- ^ The date you used in creating the signing key.+ , sigCredential :: ByteString -- ^ <your-access-key-id>/<date>/<aws-region>/<aws-service>/aws4_request+ , sigPolicy :: ByteString -- ^ The Base64-encoded security policy that describes what is permitted in the request+ , sigSignature :: ByteString -- ^ (AWS Signature Version 4) The HMAC-SHA256 hash of the security policy.+ , sigAlgorithm :: ByteString -- ^ The signing algorithm used. For AWS Signature Version 4, the value is AWS4-HMAC-SHA256.+ } deriving (Generic, Show)+++trim :: ByteString -> ByteString+trim = BS.dropWhile isSpace . fst . BS.spanEnd isSpace++s3Header :: ByteString -> ByteString -> S3Header+s3Header header value = S3Header (lower, trimmed)+ where+ lower = CI.foldCase header+ trimmed = trim value++s3HeaderBuilder :: S3Header -> Builder+s3HeaderBuilder (S3Header (header,value)) =+ mconcat [fromByteString header, ":", fromByteString value, "\n"]+
src/Network/S3/URL.hs view
@@ -1,55 +1,65 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-} module Network.S3.URL- ( putURL- , getURL- , s3URL+ ( canonicalRequest ) where -import Data.Monoid-import Data.String+import Data.Time.Format (formatTime, defaultTimeLocale)+import Data.ByteString.Char8 (pack)+import Blaze.ByteString.Builder (Builder, fromByteString) --- | S3 Upload URL Template-putURL :: (Monoid m, IsString m) => m -> m -> m -> m -> m-putURL bucket object expires mimetype =- mconcat [ "PUT\n\n"- , mimetype - ,"\n"- , expires- , "\nx-amz-acl:public-read\n/"- , bucket- , "/"- , object- ]+import qualified Network.HTTP.Types.URI as HTTP+import Data.Function (on)+import Data.List (sortBy, intersperse)+import Data.Monoid ((<>))+import Data.Maybe (fromMaybe) --- | S3 Download URL Template-getURL :: (Monoid m, IsString m) => m -> m -> m -> m-getURL bucket object expires =- mconcat [ "GET\n\n\n"- , expires- , "\n/"- , bucket- , "/"- , object- ]+import Network.S3.Types --- | Amazon S3 URL Template-baseUrl :: (Monoid m, IsString m) => m -> m -> m-baseUrl bucket object =- mconcat [ "https://"- , bucket- , ".s3.amazonaws.com/"- , object- ]+sortS3Headers :: [S3Header] -> [S3Header]+sortS3Headers = sortBy (compare `on` (fst . getS3Header)) -s3URL :: (Monoid m, IsString m) =>- m -> m -> m -> m -> m -> m-s3URL bucket object publicKey expires sig- = mconcat [ baseUrl bucket object- , "?AWSAccessKeyId="- , publicKey- , "&Expires="- , expires- , "&Signature="- , sig- ]++-- | Build a canonical request to be signed+-- AWS docs: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#canonical-request+canonicalRequest :: S3Request -> Builder+canonicalRequest S3Request{..} =+ let+ -- We MUST sort the parameters in the query string alphabetically by key name+ qs = sortBy (compare `on` fst) queryString+ emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"+ bodyHash = fromMaybe emptyHash payloadHash+ hashHeader = s3Header "x-amz-content-sha256" bodyHash+ hostHeader = s3Header "host" (bucketName <> ".s3.amazonaws.com")+ seconds = pack (formatTime defaultTimeLocale "T%M%H%SZ" requestTime)+ date = pack (formatTime defaultTimeLocale "%Y%m%d" requestTime)+ timeHeader = s3Header "x-amz-date" (date <> seconds)+ headers = sortS3Headers (timeHeader : hostHeader : hashHeader : s3headers)+ headerKeys = map (fst . getS3Header) headers++ httpMethod = renderS3Method s3method+ canonicalURI = HTTP.urlEncodeBuilder False objectName+ canonicalQS = HTTP.renderQueryText False (HTTP.queryToQueryText qs)+ canonicalHeaders = foldMap s3HeaderBuilder headers+ signedHeaders = foldMap fromByteString (intersperse ";" headerKeys)+ hashedPayload = fromByteString bodyHash++ uriBuilder =+ httpMethod <> "\n/" <>+ canonicalURI <> "\n" <>+ canonicalQS <> "\n" <>+ canonicalHeaders <> "\n" <>+ signedHeaders <> "\n" <>+ hashedPayload+ in+ uriBuilder+++renderS3Method :: S3Method -> Builder+renderS3Method method =+ case method of+ S3GET -> "GET"+ S3PUT -> "PUT"+ S3HEAD -> "HEAD"+ S3DELETE -> "DELETE"
+ test/Test.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+++module Main where++import Data.ByteString (ByteString)+import Data.Time.Calendar (fromGregorian)+import Data.Time.Clock (UTCTime(..))+import Network.S3+import Control.Monad (unless)+import Network.S3.Sign (sign)+import System.Exit (exitFailure)++expectedSig :: ByteString+expectedSig = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41"++testFail :: String -> IO ()+testFail msg = do+ print msg+ exitFailure++assertEqual :: (Show a, Eq a) => a -> a -> IO ()+assertEqual got expected = do+ let msg = "'" ++ show got ++ " does not match expected ''" ++ "'"+ unless (got == expected) (testFail msg)++main :: IO ()+main = do+ let time = UTCTime (fromGregorian 2013 5 24) 0+ sr@S3SignedRequest{..} = sign privKey (exampleRequest time) time+ assertEqual sigSignature expectedSig+ print sr++privKey :: ByteString+privKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"++pubKey :: ByteString+pubKey = "AKIAIOSFODNN7EXAMPLE"++-- https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#example-signature-GET-object+exampleRequest :: UTCTime -> S3Request+exampleRequest time =+ S3Request {+ s3method = S3GET+ , mimeType = Nothing+ , bucketName = "examplebucket"+ , regionName = "us-east-1"+ , objectName = "test.txt"+ , queryString = []+ , payloadHash = Nothing+ , requestTime = time+ , s3headers = [ s3Header "range" "bytes=0-9" ]+ }++