├── shell.nix ├── .gitignore ├── upload.nix ├── default.nix ├── s3-signer.nix ├── src └── Network │ ├── S3 │ ├── Time.hs │ ├── URL.hs │ ├── Sign.hs │ └── Types.hs │ └── S3.hs ├── LICENSE ├── test └── Test.hs ├── s3-signer.cabal ├── .travis.yml └── README.md /shell.nix: -------------------------------------------------------------------------------- 1 | (import ./default.nix {}).env 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | cabal.sandbox.config 3 | .cabal-sandbox 4 | TAGS 5 | result 6 | -------------------------------------------------------------------------------- /upload.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {}, compiler ? "ghc822" }: 2 | with pkgs.haskell.lib; 3 | { 4 | upload = sdistTarball (buildStrictly (pkgs.haskell.packages.${compiler}.callPackage ./s3-signer.nix {})); 5 | } 6 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { compiler ? "ghc843" 2 | , pkgs ? import {} 3 | }: 4 | let 5 | overrides = self: super: { 6 | s3-signer = self.callPackage ./s3-signer.nix {}; 7 | }; 8 | hPkgs = pkgs.haskell.packages.${compiler}.override { inherit overrides; }; 9 | in 10 | hPkgs.s3-signer 11 | -------------------------------------------------------------------------------- /s3-signer.nix: -------------------------------------------------------------------------------- 1 | { mkDerivation, base, base64-bytestring, blaze-builder, byteable 2 | , bytestring, case-insensitive, cryptohash, http-types, stdenv 3 | , time, utf8-string 4 | }: 5 | mkDerivation { 6 | pname = "s3-signer"; 7 | version = "0.5.0.0"; 8 | src = ./.; 9 | libraryHaskellDepends = [ 10 | base base64-bytestring blaze-builder byteable bytestring 11 | case-insensitive cryptohash http-types time utf8-string 12 | ]; 13 | testHaskellDepends = [ base blaze-builder bytestring time ]; 14 | homepage = "https://github.com/dmjio/s3-signer"; 15 | description = "Pre-signed Amazon S3 URLs"; 16 | license = stdenv.lib.licenses.bsd3; 17 | } 18 | -------------------------------------------------------------------------------- /src/Network/S3/Time.hs: -------------------------------------------------------------------------------- 1 | module Network.S3.Time 2 | ( getExpirationTimeStamp 3 | , utcTimeToEpochTime 4 | ) where 5 | 6 | import Control.Applicative ((<$>)) 7 | import Data.ByteString.UTF8 (ByteString, fromString) 8 | import Data.Time (UTCTime (..), getCurrentTime) 9 | import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds) 10 | 11 | getExpirationTimeStamp :: Integer -> IO ByteString 12 | getExpirationTimeStamp secs = fromString . show . (+secs) . utcTimeToEpochTime <$> getCurrentTime 13 | 14 | utcTimeToEpochTime :: UTCTime -> Integer 15 | utcTimeToEpochTime = fromIntegral . toSecs 16 | where 17 | toSecs :: UTCTime -> Int 18 | toSecs = round . utcTimeToPOSIXSeconds 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, David Johnson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /test/Test.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | 5 | module Main where 6 | 7 | import Data.ByteString (ByteString) 8 | import Data.Time.Calendar (fromGregorian) 9 | import Data.Time.Clock (UTCTime(..)) 10 | import Network.S3 11 | import Control.Monad (unless) 12 | import Network.S3.Sign (sign) 13 | import System.Exit (exitFailure) 14 | 15 | expectedSig :: ByteString 16 | expectedSig = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" 17 | 18 | testFail :: String -> IO () 19 | testFail msg = do 20 | print msg 21 | exitFailure 22 | 23 | assertEqual :: (Show a, Eq a) => a -> a -> IO () 24 | assertEqual got expected = do 25 | let msg = "'" ++ show got ++ " does not match expected ''" ++ "'" 26 | unless (got == expected) (testFail msg) 27 | 28 | main :: IO () 29 | main = do 30 | let time = UTCTime (fromGregorian 2013 5 24) 0 31 | sr@S3SignedRequest{..} = sign privKey (exampleRequest time) time 32 | assertEqual sigSignature expectedSig 33 | print sr 34 | 35 | privKey :: ByteString 36 | privKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" 37 | 38 | pubKey :: ByteString 39 | pubKey = "AKIAIOSFODNN7EXAMPLE" 40 | 41 | -- https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#example-signature-GET-object 42 | exampleRequest :: UTCTime -> S3Request 43 | exampleRequest time = 44 | S3Request { 45 | s3method = S3GET 46 | , mimeType = Nothing 47 | , bucketName = "examplebucket" 48 | , regionName = "us-east-1" 49 | , objectName = "test.txt" 50 | , queryString = [] 51 | , payloadHash = Nothing 52 | , requestTime = time 53 | , s3headers = [ s3Header "range" "bytes=0-9" ] 54 | } 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/Network/S3/URL.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | module Network.S3.URL 5 | ( canonicalRequest ) where 6 | 7 | import Blaze.ByteString.Builder (Builder, fromByteString) 8 | 9 | import qualified Network.HTTP.Types.URI as HTTP 10 | import Data.Function (on) 11 | import Data.List (sortBy, intersperse) 12 | import Data.Monoid ((<>)) 13 | import Data.Maybe (fromMaybe) 14 | 15 | import Network.S3.Types 16 | 17 | sortS3Headers :: [S3Header] -> [S3Header] 18 | sortS3Headers = sortBy (compare `on` (fst . getS3Header)) 19 | 20 | canonicalRequest :: S3Request -> Builder 21 | canonicalRequest S3Request{..} = 22 | let 23 | -- We MUST sort the parameters in the query string alphabetically by key name 24 | qs = sortBy (compare `on` fst) queryString 25 | bodyHash = fromMaybe "UNSIGNED-PAYLOAD" payloadHash 26 | headers = sortS3Headers s3headers 27 | headerKeys = map (fst . getS3Header) headers 28 | 29 | httpMethod = renderS3Method s3method 30 | canonicalURI = fromByteString objectName 31 | canonicalQS = HTTP.renderQueryText False (HTTP.queryToQueryText qs) 32 | canonicalHeaders = foldMap s3HeaderBuilder headers 33 | signedHeaders = foldMap fromByteString (intersperse ";" headerKeys) 34 | hashedPayload = fromByteString bodyHash 35 | 36 | uriBuilder = 37 | httpMethod <> "\n/" <> 38 | canonicalURI <> "\n" <> 39 | canonicalQS <> "\n" <> 40 | canonicalHeaders <> "\n" <> 41 | signedHeaders <> "\n" <> 42 | hashedPayload 43 | in 44 | uriBuilder 45 | 46 | renderS3Method :: S3Method -> Builder 47 | renderS3Method method = 48 | case method of 49 | S3GET -> "GET" 50 | S3PUT -> "PUT" 51 | S3HEAD -> "HEAD" 52 | S3DELETE -> "DELETE" 53 | -------------------------------------------------------------------------------- /src/Network/S3.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | module Network.S3 5 | ( -- * Create pre-signed AWS S3 URL 6 | generateS3URL 7 | -- * Types 8 | , module Network.S3.Types 9 | , module Network.S3.URL 10 | ) where 11 | 12 | import Data.ByteString.Char8 (ByteString, pack) 13 | import Data.List (intersperse) 14 | import Data.Maybe (fromMaybe) 15 | import Network.S3.Sign 16 | import Network.S3.Types 17 | import Network.S3.URL 18 | import Blaze.ByteString.Builder (toByteString, fromByteString) 19 | import qualified URI.ByteString as URI 20 | 21 | 22 | -- | Build a canonical request to be signed along with the URI containing 23 | -- all the headers in the query string 24 | generateS3URL :: S3Request -> ByteString 25 | generateS3URL req@S3Request{..} = 26 | let 27 | headers = s3Header "host" host : s3headers 28 | headerKeys = map (fst . getS3Header) headers 29 | signedHeaders = foldMap fromByteString (intersperse ";" headerKeys) 30 | 31 | qs = [ 32 | ("X-Amz-Algorithm", Just (toByteString algorithm)) 33 | , ("X-Amz-Credential", Just (toByteString (fromByteString s3Key <> "/" <> mkScope req))) 34 | , ("X-Amz-SignedHeaders", Just (toByteString signedHeaders)) 35 | , ("X-Amz-Date", Just (toByteString (mkTimestamp req))) 36 | , ("X-Amz-Expires", Just (pack (show expires))) 37 | ] ++ queryString 38 | host = "s3-" <> regionName <> ".amazonaws.com" 39 | fullObject = bucketName <> "/" <> objectName 40 | signRes = sign req{ 41 | queryString = qs 42 | , s3headers = headers 43 | , objectName = fullObject 44 | } 45 | uri = URI.URI (URI.Scheme "https") 46 | (Just (URI.Authority Nothing (URI.Host host) Nothing)) 47 | ("/" <> fullObject) 48 | (URI.Query (("X-Amz-Signature", sigSignature signRes) 49 | : (fmap (fromMaybe "") <$> qs))) 50 | Nothing 51 | in 52 | URI.serializeURIRef' uri 53 | -------------------------------------------------------------------------------- /src/Network/S3/Sign.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TupleSections #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | 5 | module Network.S3.Sign ( 6 | sign 7 | , algorithm 8 | , mkScope 9 | , mkTimestamp 10 | ) where 11 | 12 | import qualified Data.ByteString.Char8 as BS 13 | import qualified Data.ByteString.Base64 as Base64 14 | import Data.ByteString.UTF8 (ByteString) 15 | import Blaze.ByteString.Builder (toByteString, fromByteString, Builder) 16 | import Data.Monoid ((<>)) 17 | import Data.Time.Format (formatTime, defaultTimeLocale) 18 | import Network.S3.Types (S3Request(..), S3SignedRequest(..)) 19 | import Network.S3.URL (canonicalRequest) 20 | import Data.Byteable (toBytes) 21 | import Crypto.Hash 22 | 23 | reqString :: S3Request -> Digest SHA256 24 | reqString = hash . toByteString . canonicalRequest 25 | 26 | hmacSHA256 :: ByteString -> ByteString -> HMAC SHA256 27 | hmacSHA256 key = hmac key 28 | 29 | hmac_ :: ByteString -> ByteString -> ByteString 30 | hmac_ key = toBytes . hmacGetDigest . hmacSHA256 key 31 | 32 | 33 | mkScope :: S3Request -> Builder 34 | mkScope req = 35 | let 36 | date = BS.pack (formatTime defaultTimeLocale "%Y%m%d" (requestTime req)) 37 | region = regionName req 38 | service = "s3" 39 | in 40 | fromByteString date <> fromByteString "/" <> 41 | fromByteString region <> fromByteString "/" <> 42 | fromByteString service <> fromByteString "/aws4_request" 43 | 44 | algorithm :: Builder 45 | algorithm = "AWS4-HMAC-SHA256" 46 | 47 | mkTimestamp :: S3Request -> Builder 48 | mkTimestamp req = 49 | let 50 | seconds = BS.pack (formatTime defaultTimeLocale "T%H%M%SZ" (requestTime req)) 51 | date = BS.pack (formatTime defaultTimeLocale "%Y%m%d" (requestTime req)) 52 | in 53 | fromByteString (date <> seconds) 54 | 55 | -- | SHA1 Encrypted Signature 56 | sign :: S3Request -> S3SignedRequest 57 | sign req = 58 | let 59 | date = BS.pack (formatTime defaultTimeLocale "%Y%m%d" (requestTime req)) 60 | timestamp = mkTimestamp req 61 | reqHash = fromByteString (digestToHexByteString (reqString req)) 62 | region = regionName req 63 | service = "s3" 64 | 65 | dateKey = hmac_ ("AWS4" <> s3Secret req) date 66 | dateRegionKey = hmac_ dateKey region 67 | dateRegionServiceKey = hmac_ dateRegionKey service 68 | signingKey = hmac_ dateRegionServiceKey "aws4_request" 69 | 70 | scope = mkScope req 71 | 72 | signingStringBuilder = 73 | algorithm <> "\n" <> 74 | timestamp <> "\n" <> 75 | scope <> "\n" <> 76 | reqHash 77 | 78 | stringToSign = toByteString signingStringBuilder 79 | signature = hmacSHA256 signingKey stringToSign 80 | hexSignature = digestToHexByteString (hmacGetDigest signature) 81 | in 82 | S3SignedRequest { 83 | sigHeaders = s3headers req 84 | , sigDate = toByteString timestamp 85 | , sigCredential = toByteString scope 86 | , sigPolicy = Base64.encode stringToSign 87 | , sigSignature = hexSignature 88 | , sigAlgorithm = toByteString algorithm 89 | } 90 | -------------------------------------------------------------------------------- /s3-signer.cabal: -------------------------------------------------------------------------------- 1 | name: s3-signer 2 | version: 0.5.0.0 3 | homepage: https://github.com/dmjio/s3-signer 4 | bug-reports: https://github.com/dmjio/s3-signer/issues 5 | synopsis: Pre-signed Amazon S3 URLs 6 | description: 7 | . 8 | s3-signer creates cryptographically secure Amazon S3 URLs that expire within a user-defined 9 | period. It allows uploading and downloading of content from Amazon S3. 10 | Ideal for AJAX direct-to-s3 uploads via CORS and secure downloads. 11 | Web framework agnostic with minimal dependencies. 12 | . 13 | > module Main where 14 | > import Network.S3 15 | > main :: IO () 16 | > main = print =<< generateS3URL credentials request 17 | > where 18 | > credentials = S3Keys "" "" 19 | > request = S3Request S3GET "application/extension" "bucket-name" "file-name.extension" 3 -- three seconds until expiration 20 | . 21 | Result 22 | . 23 | > S3URL "https://bucket-name.s3.amazonaws.com/file-name.extension?AWSAccessKeyId=&Expires=1402346638&Signature=1XraY%2Bhp117I5CTKNKPc6%2BiihRA%3D" 24 | 25 | license: BSD3 26 | license-file: LICENSE 27 | author: David Johnson , William Casarin 28 | maintainer: David Johnson 29 | copyright: David Johnson (c) 2014-2025 30 | category: AWS, Network 31 | build-type: Simple 32 | cabal-version: 2.0 33 | extra-source-files: README.md 34 | tested-with: GHC == 8.0.2, GHC == 8.2.2, GHC == 8.4.1 35 | 36 | library 37 | build-depends: base == 4.* 38 | , base64-bytestring 39 | , bytestring 40 | , byteable 41 | , utf8-string 42 | , case-insensitive >= 1.2 43 | , blaze-builder >= 0.4 44 | , http-types 45 | , cryptohash 46 | , time 47 | , uri-bytestring 48 | hs-source-dirs: src 49 | default-language: Haskell2010 50 | ghc-options: -Wall 51 | exposed-modules: Network.S3 52 | other-modules: Network.S3.Sign 53 | , Network.S3.Time 54 | , Network.S3.URL 55 | , Network.S3.Types 56 | 57 | 58 | test-suite test-simple 59 | default-language: Haskell2010 60 | hs-source-dirs: src, test 61 | type: exitcode-stdio-1.0 62 | main-is: Test.hs 63 | other-modules: Network.S3 64 | , Network.S3.Sign 65 | , Network.S3.Time 66 | , Network.S3.URL 67 | , Network.S3.Types 68 | build-depends: base == 4.* 69 | , base64-bytestring 70 | , bytestring 71 | , byteable 72 | , utf8-string 73 | , case-insensitive >= 1.2 74 | , blaze-builder >= 0.4 75 | , http-types 76 | , cryptohash 77 | , time 78 | , uri-bytestring 79 | 80 | source-repository head 81 | type: git 82 | location: https://github.com/dmjio/s3-signer 83 | -------------------------------------------------------------------------------- /src/Network/S3/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | 4 | module Network.S3.Types 5 | ( S3Method (..) 6 | , S3Request (..) 7 | , S3SignedRequest (..) 8 | , S3Header 9 | , getS3Header 10 | , s3Header 11 | , s3HeaderBuilder 12 | , emptyHash 13 | ) where 14 | 15 | import Data.ByteString.UTF8 (ByteString) 16 | import GHC.Generics (Generic) 17 | import Data.Char (isSpace) 18 | import Network.HTTP.Types (Query) 19 | import Data.Time.Clock (UTCTime) 20 | 21 | import Blaze.ByteString.Builder (Builder, fromByteString) 22 | import Data.Monoid (mconcat) 23 | import qualified Data.ByteString.Char8 as BS 24 | import qualified Data.CaseInsensitive as CI 25 | 26 | 27 | newtype S3Header = S3Header { getS3Header :: (ByteString, ByteString) } 28 | deriving (Generic, Show) 29 | 30 | 31 | data S3Method = S3GET -- ^ GET Request 32 | | S3PUT -- ^ PUT Request 33 | | S3HEAD -- ^ HEAD Request 34 | | S3DELETE -- ^ DELETE Request 35 | deriving (Generic, Show) 36 | 37 | 38 | data S3Request = S3Request { 39 | s3method :: S3Method -- ^ Type of HTTP Method 40 | , mimeType :: Maybe ByteString -- ^ MIME Type 41 | , bucketName :: ByteString -- ^ Name of Amazon S3 Bucket 42 | , objectName :: ByteString -- ^ Name of Amazon S3 File 43 | , regionName :: ByteString -- ^ Name of Amazon S3 Region 44 | , queryString :: Query -- ^ Optional query string items 45 | , requestTime :: UTCTime -- ^ Requests are valid within a 15 minute window of this timestamp 46 | , payloadHash :: Maybe ByteString -- ^ SHA256 hash string of the payload; Nothing if unsigned 47 | , s3headers :: [S3Header] -- ^ Headers 48 | , expires :: Int -- ^ Expiration in seconds 49 | , s3Key :: ByteString 50 | , s3Secret :: ByteString 51 | } deriving (Generic, Show) 52 | 53 | -- | Hash of empty payload 54 | emptyHash :: ByteString 55 | emptyHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 56 | 57 | data S3SignedRequest = S3SignedRequest { 58 | sigHeaders :: [S3Header] -- ^ The headers included in the signed request 59 | , sigDate :: ByteString -- ^ The date you used in creating the signing key. 60 | , sigCredential :: ByteString -- ^ ////aws4_request 61 | , sigPolicy :: ByteString -- ^ The Base64-encoded security policy that describes what is permitted in the request 62 | , sigSignature :: ByteString -- ^ (AWS Signature Version 4) The HMAC-SHA256 hash of the security policy. 63 | , sigAlgorithm :: ByteString -- ^ The signing algorithm used. For AWS Signature Version 4, the value is AWS4-HMAC-SHA256. 64 | } deriving (Generic, Show) 65 | 66 | 67 | trim :: ByteString -> ByteString 68 | trim = BS.dropWhile isSpace . fst . BS.spanEnd isSpace 69 | 70 | s3Header :: ByteString -> ByteString -> S3Header 71 | s3Header header value = S3Header (lower, trimmed) 72 | where 73 | lower = CI.foldCase header 74 | trimmed = trim value 75 | 76 | s3HeaderBuilder :: S3Header -> Builder 77 | s3HeaderBuilder (S3Header (header,value)) = 78 | mconcat [fromByteString header, ":", fromByteString value, "\n"] 79 | 80 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # This Travis job script has been generated by a script via 2 | # 3 | # runghc make_travis_yml_2.hs 's3-signer.cabal' 4 | # 5 | # For more information, see https://github.com/hvr/multi-ghc-travis 6 | # 7 | language: c 8 | sudo: false 9 | 10 | git: 11 | submodules: false # whether to recursively clone submodules 12 | 13 | cache: 14 | directories: 15 | - $HOME/.cabal/packages 16 | - $HOME/.cabal/store 17 | 18 | before_cache: 19 | - rm -fv $HOME/.cabal/packages/hackage.haskell.org/build-reports.log 20 | # remove files that are regenerated by 'cabal update' 21 | - rm -fv $HOME/.cabal/packages/hackage.haskell.org/00-index.* 22 | - rm -fv $HOME/.cabal/packages/hackage.haskell.org/*.json 23 | - rm -fv $HOME/.cabal/packages/hackage.haskell.org/01-index.cache 24 | - rm -fv $HOME/.cabal/packages/hackage.haskell.org/01-index.tar 25 | - rm -fv $HOME/.cabal/packages/hackage.haskell.org/01-index.tar.idx 26 | 27 | - rm -rfv $HOME/.cabal/packages/head.hackage 28 | 29 | matrix: 30 | include: 31 | - compiler: "ghc-8.0.2" 32 | # env: TEST=--disable-tests BENCH=--disable-benchmarks 33 | addons: {apt: {packages: [ghc-ppa-tools,cabal-install-head,ghc-8.0.2], sources: [hvr-ghc]}} 34 | - compiler: "ghc-8.2.2" 35 | # env: TEST=--disable-tests BENCH=--disable-benchmarks 36 | addons: {apt: {packages: [ghc-ppa-tools,cabal-install-head,ghc-8.2.2], sources: [hvr-ghc]}} 37 | - compiler: "ghc-8.4.1" 38 | # env: TEST=--disable-tests BENCH=--disable-benchmarks 39 | addons: {apt: {packages: [ghc-ppa-tools,cabal-install-head,ghc-8.4.1], sources: [hvr-ghc]}} 40 | 41 | before_install: 42 | - HC=${CC} 43 | - HCPKG=${HC/ghc/ghc-pkg} 44 | - unset CC 45 | - ROOTDIR=$(pwd) 46 | - mkdir -p $HOME/.local/bin 47 | - "PATH=/opt/ghc/bin:/opt/ghc-ppa-tools/bin:$HOME/local/bin:$PATH" 48 | - HCNUMVER=$(( $(${HC} --numeric-version|sed -E 's/([0-9]+)\.([0-9]+)\.([0-9]+).*/\1 * 10000 + \2 * 100 + \3/') )) 49 | - echo $HCNUMVER 50 | 51 | install: 52 | - cabal --version 53 | - echo "$(${HC} --version) [$(${HC} --print-project-git-commit-id 2> /dev/null || echo '?')]" 54 | - BENCH=${BENCH---enable-benchmarks} 55 | - TEST=${TEST---enable-tests} 56 | - HADDOCK=${HADDOCK-true} 57 | - INSTALLED=${INSTALLED-true} 58 | - GHCHEAD=${GHCHEAD-false} 59 | - travis_retry cabal update -v 60 | - "sed -i.bak 's/^jobs:/-- jobs:/' ${HOME}/.cabal/config" 61 | - rm -fv cabal.project cabal.project.local 62 | - grep -Ev -- '^\s*--' ${HOME}/.cabal/config | grep -Ev '^\s*$' 63 | - "printf 'packages: \".\"\\n' > cabal.project" 64 | - cat cabal.project 65 | - if [ -f "./configure.ac" ]; then 66 | (cd "." && autoreconf -i); 67 | fi 68 | - rm -f cabal.project.freeze 69 | - cabal new-build -w ${HC} ${TEST} ${BENCH} --project-file="cabal.project" --dep -j2 all 70 | - cabal new-build -w ${HC} --disable-tests --disable-benchmarks --project-file="cabal.project" --dep -j2 all 71 | - rm -rf .ghc.environment.* "."/dist 72 | - DISTDIR=$(mktemp -d /tmp/dist-test.XXXX) 73 | 74 | # Here starts the actual work to be performed for the package under test; 75 | # any command which exits with a non-zero exit code causes the build to fail. 76 | script: 77 | # test that source-distributions can be generated 78 | - (cd "." && cabal sdist) 79 | - mv "."/dist/s3-signer-*.tar.gz ${DISTDIR}/ 80 | - cd ${DISTDIR} || false 81 | - find . -maxdepth 1 -name '*.tar.gz' -exec tar -xvf '{}' \; 82 | - "printf 'packages: s3-signer-*/*.cabal\\n' > cabal.project" 83 | - cat cabal.project 84 | # this builds all libraries and executables (without tests/benchmarks) 85 | - cabal new-build -w ${HC} --disable-tests --disable-benchmarks all 86 | 87 | # Build with installed constraints for packages in global-db 88 | - if $INSTALLED; then echo cabal new-build -w ${HC} --disable-tests --disable-benchmarks $(${HCPKG} list --global --simple-output --names-only | sed 's/\([a-zA-Z0-9-]\{1,\}\) */--constraint="\1 installed" /g') all | sh; else echo "Not building with installed constraints"; fi 89 | 90 | # build & run tests, build benchmarks 91 | - cabal new-build -w ${HC} ${TEST} ${BENCH} all 92 | - if [ "x$TEST" = "x--enable-tests" ]; then cabal new-test -w ${HC} ${TEST} ${BENCH} all; fi 93 | 94 | # cabal check 95 | - (cd s3-signer-* && cabal check) 96 | 97 | # haddock 98 | - rm -rf ./dist-newstyle 99 | - if $HADDOCK; then cabal new-haddock -w ${HC} ${TEST} ${BENCH} all; else echo "Skipping haddock generation";fi 100 | 101 | # REGENDATA ["s3-signer.cabal"] 102 | # EOF 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | s3-signer 2 | ====== 3 | [![Hackage](https://img.shields.io/hackage/v/s3-signer.svg)](https://hackage.haskell.org/package/s3-signer) 4 | ![Hackage Dependencies](https://img.shields.io/hackage-deps/v/s3-signer.svg) 5 | ![Haskell Programming Language](https://img.shields.io/badge/language-Haskell-blue.svg) 6 | ![BSD3 License](http://img.shields.io/badge/license-BSD3-brightgreen.svg) 7 | [![Build Status](https://travis-ci.org/dmjio/s3-signer.svg?branch=master)](https://travis-ci.org/dmjio/s3-signer) 8 | 9 | s3-signer is intended to be an aid in building secure cloud-based services with 10 | AWS. This library generates cryptographically secure URLs that 11 | expire at a user-defined interval. These URLs can be used to offload 12 | the process of uploading and downloading large files, freeing your 13 | webserver to focus on other things. 14 | 15 | ### Features 16 | - Minimal depedencies 17 | - Web framework agnostic 18 | - Reduces web server load 19 | - Simple API 20 | - Ideal for AJAX direct-to-s3 upload scenarios 21 | 22 | ### Documentation 23 | [S3 Query String Request Authentication](http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html#RESTAuthenticationQueryStringAuth) 24 | 25 | ### Implementation 26 | 27 | > AWS Specification 28 | 29 | ```shell 30 | Signature = URL-Encode( Base64( HMAC-SHA1( YourSecretAccessKeyID,UTF-8-Encoding-Of( StringToSign ) ) ) ); 31 | ``` 32 | > Haskell Implementation 33 | 34 | ```haskell 35 | module Network.S3.Sign ( sign ) where 36 | 37 | import Crypto.Hash.SHA1 (hash) 38 | import Crypto.MAC.HMAC (hmac) 39 | import qualified Data.ByteString.Base64 as B64 40 | import Data.ByteString.UTF8 (ByteString) 41 | import Network.HTTP.Types.URI (urlEncode) 42 | 43 | -- | HMAC-SHA1 Encrypted Signature 44 | sign :: ByteString -> ByteString -> ByteString 45 | sign secretKey url = urlEncode True . B64.encode $ hmac hash 64 secretKey url 46 | ``` 47 | 48 | ### Use Case 49 | ```haskell 50 | {-# LANGUAGE OverloadedStrings #-} 51 | 52 | module Main where 53 | 54 | import Network.S3 55 | 56 | main :: IO () 57 | main = print =<< generateS3URL credentials request 58 | where 59 | credentials = S3Keys "" "" 60 | request = S3Request S3GET "application/zip" "bucket-name" "file-name.extension" 3 -- 3 secs until expired 61 | ``` 62 | ### Result 63 | ```haskell 64 | S3URL { 65 | signedRequest = 66 | "https://bucket-name.s3.amazonaws.com/file-name.extension?AWSAccessKeyId=&Expires=1402346638&Signature=1XraY%2Bhp117I5CTKNKPc6%2BiihRA%3D" 67 | } 68 | ``` 69 | 70 | ### Snap integration - Downloads 71 | ```haskell 72 | -- Quick and dirty example 73 | type FileID = ByteString 74 | 75 | makeS3URL :: FileID -> IO S3URL 76 | makeS3URL fileId = generateS3URL credentials request 77 | where 78 | credentials = S3Keys "" "" 79 | request = S3Request S3GET "application/zip" "bucket-name" (fileId <> ".zip") 3 80 | 81 | downloadFile :: Handler App (AuthManager App) () 82 | downloadFile = method POST $ currentUserId >>= maybe the404 handleDownload 83 | where handleDownload uid = do 84 | Just fileId <- getParam "fileId" 85 | -- Ensure file being requested belongs to user else 403... 86 | S3URL url <- liftIO $ makeS3URL fileId 87 | redirect' url 302 88 | ``` 89 | ### Direct to S3 AJAX Uploads 90 | - Configure S3 Bucket CORS Policy settings 91 | - [CORS Docs](http://docs.aws.amazon.com/AmazonS3/latest/dev/cors.html#how-do-i-enable-cors) 92 | 93 | ```xml 94 | 95 | 96 | 97 | https://my-url-goes-here.com 98 | PUT 99 | * 100 | 101 | 102 | ``` 103 | - Retrieve PUT Request URL via AJAX 104 | 105 | ```haskell 106 | type FileID = ByteString 107 | 108 | makeS3URL :: FileID -> IO S3URL 109 | makeS3URL fileId = generateS3URL credentials request 110 | where 111 | credentials = S3Keys "" "" 112 | request = S3Request S3PUT "application/zip" "bucket-name" (fileId <> ".zip") 3 113 | 114 | getUploadURL :: Handler App (AuthManager App) () 115 | getUploadURL = method POST $ currentUserId >>= maybe the404 handleDownload 116 | where handleDownload _ = do 117 | Just fileId <- getParam "fileId" 118 | writeJSON =<< Data.Text.Encoding.decodeUtf8 <$> liftIO (makeS3URL fileId) 119 | ``` 120 | - Embed FileReader blob data to request 121 | - Send upload request 122 | 123 | ```javascript 124 | var xhr = new XMLHttpRequest(); 125 | xhr.open('PUT', url /* S3-URL generated from server */); 126 | xhr.setRequestHeader('Content-Type', 'application/zip'); /* whatever http-content-type makes sense */ 127 | xhr.setRequestHeader('x-amz-acl', 'public-read'); 128 | 129 | /* upload completion check */ 130 | xhr.onreadystatechange = function(e) { 131 | if (this.readyState === 4 && this.status === 200) 132 | console.log('upload complete'); 133 | }; 134 | 135 | /* Amazon gives you progress information on AJAX Uploads */ 136 | xhr.upload.addEventListener("progress", function(evt) { 137 | if (evt.lengthComputable) { 138 | var v = (evt.loaded / evt.total) * 100, 139 | val = Math.round(v) + '%', 140 | console.log('Completed: ' + val); 141 | } 142 | }, false); 143 | 144 | /* error handling */ 145 | xhr.upload.addEventListener("error", function(evt) { 146 | console.log("There has been an error :("); 147 | }, false); 148 | 149 | /* Commence upload */ 150 | xhr.send(file); // file here is a blob from the file reader API 151 | ``` 152 | ### File Reader Info 153 | [How to read file data from the browser](https://developer.mozilla.org/en-US/docs/Web/API/FileReader) 154 | 155 | ### Troubleshoooting 156 | - Why do I keep getting 403 forbidden when I attempt to upload or download from a pre-signed URL? 157 | * Ask yourself the following: 158 | - Are my keys specified correctly? 159 | - Did I configure the CORS settings on my bucket properly? 160 | - Still trouble? [Make an issue](https://github.com/dmjio/s3-signer/issues) 161 | - Why are my URLs expiring faster than the specified time? 162 | * Ask yourself the following: 163 | - Is my server's clock synchronized with AWS? [See wiki for NTP info](https://github.com/dmjio/s3-signer/wiki/If-URLs-expire-too-quickly) 164 | 165 | ### FAQ 166 | - Why didn't you use HMAC-SHA256? 167 | * It's 30% slower, and for all intents and purposes no more secure 168 | than HMAC-SHA1 (no known vulnerabilities exist for it, to my knowledge). Plain 169 | SHA1 is a different story. Collisions can be found, but there is 170 | no known way to apply those to HMAC-SHA1. 171 | * For the curious [SHA-1 is broken](https://www.schneier.com/blog/archives/2005/02/sha1_broken.html) 172 | * For the paranoid (Schneier quote from same article above) 173 | * [Relevant SO Post](http://stackoverflow.com/questions/3334524/hmac-security-is-the-security-of-the-hmac-based-on-sha-1-affected-by-the-colli) 174 | 175 | > This attack builds on previous attacks on SHA-0 and SHA-1, and is 176 | > a major, major cryptanalytic result. It pretty much puts a bullet 177 | > into SHA-1 as a hash function for digital signatures (although it 178 | > **doesn't** **affect** applications such as **HMAC** where collisions aren't important). 179 | 180 | 181 | 182 | 183 | 184 | --------------------------------------------------------------------------------