@@ -18,19 +18,22 @@
1818module Network.Minio.Sign.V4 where
1919
2020import qualified Conduit as C
21+import qualified Data.ByteArray as BA
2122import qualified Data.ByteString as B
2223import qualified Data.ByteString.Base64 as Base64
2324import qualified Data.ByteString.Char8 as B8
2425import qualified Data.ByteString.Lazy as LB
25−import Data.CaseInsensitive (mk)
2626import qualified Data.CaseInsensitive as CI
2727import qualified Data.HashMap.Strict as Map
2828import qualified Data.HashSet as Set
29+import Data.List (partition)
30+import qualified Data.List.NonEmpty as NE
2931import qualified Data.Time as Time
3032import Lib.Prelude
3133import qualified Network.HTTP.Conduit as NC
32−import Network.HTTP.Types (Header, parseQuery)
34+import Network.HTTP.Types (Header, SimpleQuery, hContentEncoding, parseQuery)
3335import qualified Network.HTTP.Types as H
36+import Network.HTTP.Types.Header (RequestHeaders)
3437import Network.Minio.Data.ByteString
3538import Network.Minio.Data.Crypto
3639import Network.Minio.Data.Time
@@ -60,9 +63,17 @@ data SignV4Data = SignV4Data
6063 }
6164 deriving stock (Show)
6265
66+data Service = ServiceS3 | ServiceSTS
67+ deriving stock (Eq, Show)
68+
69+toByteString :: Service -> ByteString
70+toByteString ServiceS3 = "s3"
71+toByteString ServiceSTS = "sts"
72+
6373data SignParams = SignParams
6474 { spAccessKey :: Text,
65− spSecretKey :: Text,
75+ spSecretKey :: BA.ScrubbedBytes,
76+ spService :: Service,
6677 spTimeStamp :: UTCTime,
6778 spRegion :: Maybe Text,
6879 spExpirySecs :: Maybe UrlExpiry,
@@ -102,6 +113,9 @@ mkAuthHeader accessKey scope signedHeaderKeys sign =
102113 ]
103114 in (H.hAuthorization, authValue)
104115
116+data IsStreaming = IsStreamingLength Int64 | NotStreaming
117+ deriving stock (Eq, Show)
118+
105119-- | Given SignParams and request details, including request method,
106120-- request path, headers, query params and payload hash, generates an
107121-- updated set of headers, including the x-amz-date header and the
@@ -114,33 +128,19 @@ mkAuthHeader accessKey scope signedHeaderKeys sign =
114128-- is being created. The expiry is interpreted as an integer number of
115129-- seconds. The output will be the list of query-parameters to add to
116130-- the request.
117−signV4 :: SignParams -> NC.Request -> [(ByteString, ByteString)]
118−signV4 !sp !req =
119− let region = fromMaybe "" $ spRegion sp
120− ts = spTimeStamp sp
121− scope = mkScope ts region
122− accessKey = encodeUtf8 $ spAccessKey sp
123− secretKey = encodeUtf8 $ spSecretKey sp
131+signV4QueryParams :: SignParams -> NC.Request -> SimpleQuery
132+signV4QueryParams !sp !req =
133+ let scope = credentialScope sp
124134 expiry = spExpirySecs sp
125− sha256Hdr =
126− ( "x-amz-content-sha256",
127− fromMaybe "UNSIGNED-PAYLOAD" $ spPayloadHash sp
128− )
129− -- headers to be added to the request
130− datePair = ("X-Amz-Date", awsTimeFormatBS ts)
131− computedHeaders =
132− NC.requestHeaders req
133− ++ if isJust expiry
134− then []
135− else map (first mk) [datePair, sha256Hdr]
136− headersToSign = getHeadersToSign computedHeaders
135+
136+ headersToSign = getHeadersToSign $ NC.requestHeaders req
137137 signedHeaderKeys = B.intercalate ";" $ sort $ map fst headersToSign
138138 -- query-parameters to be added before signing for presigned URLs
139139 -- (i.e. when `isJust expiry`)
140140 authQP =
141141 [ ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"),
142− ("X-Amz-Credential", B.concat [accessKey, "/", scope]),
143− datePair,
142+ ("X-Amz-Credential", B.concat [encodeUtf8 $ spAccessKey sp, "/", scope]),
143+ ("X-Amz-Date", awsTimeFormatBS $ spTimeStamp sp),
144144 ("X-Amz-Expires", maybe "" showBS expiry),
145145 ("X-Amz-SignedHeaders", signedHeaderKeys)
146146 ]
@@ -156,40 +156,129 @@ signV4 !sp !req =
156156 sp
157157 (NC.setQueryString finalQP req)
158158 headersToSign
159+
159160 -- 2. compute string to sign
160− stringToSign = mkStringToSign ts scope canonicalRequest
161+ stringToSign = mkStringToSign (spTimeStamp sp) scope canonicalRequest
161162 -- 3.1 compute signing key
162− signingKey = mkSigningKey ts region secretKey
163+ signingKey = getSigningKey sp
164+ -- 3.2 compute signature
165+ signature = computeSignature stringToSign signingKey
166+ in ("X-Amz-Signature", signature) : authQP
167+
168+-- | Given SignParams and request details, including request method, request
169+-- path, headers, query params and payload hash, generates an updated set of
170+-- headers, including the x-amz-date header and the Authorization header, which
171+-- includes the signature.
172+--
173+-- The output is the list of headers to be added to authenticate the request.
174+signV4 :: SignParams -> NC.Request -> [Header]
175+signV4 !sp !req =
176+ let scope = credentialScope sp
177+
178+ -- extra headers to be added for signing purposes.
179+ extraHeaders =
180+ ("X-Amz-Date", awsTimeFormatBS $ spTimeStamp sp)
181+ : ( -- payload hash is only used for S3 (not STS)
182+ [ ( "x-amz-content-sha256",
183+ fromMaybe "UNSIGNED-PAYLOAD" $ spPayloadHash sp
184+ )
185+ | spService sp == ServiceS3
186+ ]
187+ )
188+
189+ -- 1. compute canonical request
190+ reqHeaders = NC.requestHeaders req ++ extraHeaders
191+ (canonicalRequest, signedHeaderKeys) =
192+ getCanonicalRequestAndSignedHeaders
193+ NotStreaming
194+ sp
195+ req
196+ reqHeaders
197+
198+ -- 2. compute string to sign
199+ stringToSign = mkStringToSign (spTimeStamp sp) scope canonicalRequest
200+ -- 3.1 compute signing key
201+ signingKey = getSigningKey sp
163202 -- 3.2 compute signature
164203 signature = computeSignature stringToSign signingKey
165204 -- 4. compute auth header
166205 authHeader = mkAuthHeader (spAccessKey sp) scope signedHeaderKeys signature
167− -- finally compute output pairs
168− output =
169− if isJust expiry
170− then ("X-Amz-Signature", signature) : authQP
171− else
172− [ first CI.foldedCase authHeader,
173− datePair,
174− sha256Hdr
175− ]
176− in output
177−
178−mkScope :: UTCTime -> Text -> ByteString
179−mkScope ts region =
180− B.intercalate
181− "/"
182− [ encodeUtf8 $ Time.formatTime Time.defaultTimeLocale "%Y%m%d" ts,
183− encodeUtf8 region,
184− "s3",
185− "aws4_request"
186− ]
206+ in authHeader : extraHeaders
187207
208+credentialScope :: SignParams -> ByteString
209+credentialScope sp =
210+ let region = fromMaybe "" $ spRegion sp
211+ in B.intercalate
212+ "/"
213+ [ encodeUtf8 $ Time.formatTime Time.defaultTimeLocale "%Y%m%d" $ spTimeStamp sp,
214+ encodeUtf8 region,
215+ toByteString $ spService sp,
216+ "aws4_request"
217+ ]
218+
219+-- Folds header name, trims whitespace in header values, skips ignored headers
220+-- and sorts headers.
188221getHeadersToSign :: [Header] -> [(ByteString, ByteString)]
189222getHeadersToSign !h =
190223 filter ((\hdr -> not $ Set.member hdr ignoredHeaders) . fst) $
191224 map (bimap CI.foldedCase stripBS) h
192225
226+-- | Given the list of headers in the request, computes the canonical headers
227+-- and the signed headers strings.
228+getCanonicalHeaders :: NonEmpty Header -> (ByteString, ByteString)
229+getCanonicalHeaders h =
230+ let -- Folds header name, trims spaces in header values, skips ignored
231+ -- headers and sorts headers by name (we must not re-order multi-valued
232+ -- headers).
233+ headersToSign =
234+ NE.toList $
235+ NE.sortBy (\a b -> compare (fst a) (fst b)) $
236+ NE.fromList $
237+ NE.filter ((\hdr -> not $ Set.member hdr ignoredHeaders) . fst) $
238+ NE.map (bimap CI.foldedCase stripBS) h
239+
240+ canonicalHeaders = mconcat $ map (\(a, b) -> a <> ":" <> b <> "\n") headersToSign
241+ signedHeaderKeys = B.intercalate ";" $ map fst headersToSign
242+ in (canonicalHeaders, signedHeaderKeys)
243+
244+getCanonicalRequestAndSignedHeaders ::
245+ IsStreaming ->
246+ SignParams ->
247+ NC.Request ->
248+ [Header] ->
249+ (ByteString, ByteString)
250+getCanonicalRequestAndSignedHeaders isStreaming sp req requestHeaders =
251+ let httpMethod = NC.method req
252+
253+ canonicalUri = uriEncode False $ NC.path req
254+
255+ canonicalQueryString =
256+ B.intercalate "&" $
257+ map (\(x, y) -> B.concat [x, "=", y]) $
258+ sort $
259+ map
260+ ( bimap (uriEncode True) (maybe "" (uriEncode True))
261+ )
262+ (parseQuery $ NC.queryString req)
263+
264+ (canonicalHeaders, signedHeaderKeys) = getCanonicalHeaders $ NE.fromList requestHeaders
265+ payloadHashStr =
266+ case isStreaming of
267+ IsStreamingLength _ -> "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
268+ NotStreaming -> fromMaybe "UNSIGNED-PAYLOAD" $ spPayloadHash sp
269+
270+ canonicalRequest =
271+ B.intercalate
272+ "\n"
273+ [ httpMethod,
274+ canonicalUri,
275+ canonicalQueryString,
276+ canonicalHeaders,
277+ signedHeaderKeys,
278+ payloadHashStr
279+ ]
280+ in (canonicalRequest, signedHeaderKeys)
281+
193282mkCanonicalRequest ::
194283 Bool ->
195284 SignParams ->
@@ -197,10 +286,12 @@ mkCanonicalRequest ::
197286 [(ByteString, ByteString)] ->
198287 ByteString
199288mkCanonicalRequest !isStreaming !sp !req !headersForSign =
200− let canonicalQueryString =
289+ let httpMethod = NC.method req
290+ canonicalUri = uriEncode False $ NC.path req
291+ canonicalQueryString =
201292 B.intercalate "&" $
202293 map (\(x, y) -> B.concat [x, "=", y]) $
203− sort $
294+ sortBy (\a b -> compare (fst a) (fst b)) $
204295 map
205296 ( bimap (uriEncode True) (maybe "" (uriEncode True))
206297 )
@@ -216,8 +307,8 @@ mkCanonicalRequest !isStreaming !sp !req !headersForSign =
216307 else fromMaybe "UNSIGNED-PAYLOAD" $ spPayloadHash sp
217308 in B.intercalate
218309 "\n"
219− [ NC.method req,
220− uriEncode False $ NC.path req,
310+ [ httpMethod,
311+ canonicalUri,
221312 canonicalQueryString,
222313 canonicalHeaders,
223314 signedHeaders,
@@ -234,13 +325,13 @@ mkStringToSign ts !scope !canonicalRequest =
234325 hashSHA256 canonicalRequest
235326 ]
236327
237−mkSigningKey :: UTCTime -> Text -> ByteString -> ByteString
238−mkSigningKey ts region !secretKey =
328+getSigningKey :: SignParams -> ByteString
329+getSigningKey sp =
239330 hmacSHA256RawBS "aws4_request"
240− . hmacSHA256RawBS "s3"
241− . hmacSHA256RawBS (encodeUtf8 region)
242− . hmacSHA256RawBS (awsDateFormatBS ts)
243− $ B.concat ["AWS4", secretKey]
331+ . hmacSHA256RawBS (toByteString $ spService sp)
332+ . hmacSHA256RawBS (encodeUtf8 $ fromMaybe "" $ spRegion sp)
333+ . hmacSHA256RawBS (awsDateFormatBS $ spTimeStamp sp)
334+ $ B.concat ["AWS4", BA.convert $ spSecretKey sp]
244335
245336computeSignature :: ByteString -> ByteString -> ByteString
246337computeSignature !toSign !key = digestToBase16 $ hmacSHA256 toSign key
@@ -254,8 +345,7 @@ signV4PostPolicy ::
254345 Map.HashMap Text ByteString
255346signV4PostPolicy !postPolicyJSON !sp =
256347 let stringToSign = Base64.encode postPolicyJSON
257− region = fromMaybe "" $ spRegion sp
258− signingKey = mkSigningKey (spTimeStamp sp) region $ encodeUtf8 $ spSecretKey sp
348+ signingKey = getSigningKey sp
259349 signature = computeSignature stringToSign signingKey
260350 in Map.fromList
261351 [ ("x-amz-signature", signature),
@@ -284,60 +374,59 @@ signedStreamLength dataLen =
284374 finalChunkSize = 1 + 17 + 64 + 2 + 2
285375 in numChunks * fullChunkSize + lastChunkSize + finalChunkSize
286376
377+-- For streaming S3, we need to update the content-encoding header.
378+addContentEncoding :: [Header] -> [Header]
379+addContentEncoding hs =
380+ -- assume there is at most one content-encoding header.
381+ let (ceHdrs, others) = partition ((== hContentEncoding) . fst) hs
382+ in maybe
383+ (hContentEncoding, "aws-chunked")
384+ (\(k, v) -> (k, v <> ",aws-chunked"))
385+ (listToMaybe ceHdrs)
386+ : others
387+
287388signV4Stream ::
288389 Int64 ->
289390 SignParams ->
290391 NC.Request ->
291392 (C.ConduitT () ByteString (C.ResourceT IO) () -> NC.Request)
292−-- -> ([Header], C.ConduitT () ByteString (C.ResourceT IO) () -> NC.RequestBody)
293393signV4Stream !payloadLength !sp !req =
294394 let ts = spTimeStamp sp
295− addContentEncoding hs =
296− let ceMay = find (\(x, _) -> x == "content-encoding") hs
297− in case ceMay of
298− Nothing -> ("content-encoding", "aws-chunked") : hs
299− Just (_, ce) ->
300− ("content-encoding", ce <> ",aws-chunked")
301− : filter (\(x, _) -> x /= "content-encoding") hs
302− -- headers to be added to the request
303− datePair = ("X-Amz-Date", awsTimeFormatBS ts)
304− computedHeaders =
305− addContentEncoding $
306− datePair : NC.requestHeaders req
307− -- headers specific to streaming signature
395+
396+ -- compute the updated list of headers to be added for signing purposes.
308397 signedContentLength = signedStreamLength payloadLength
309− streamingHeaders :: [Header]
310− streamingHeaders =
311− [ ("x-amz-decoded-content-length", showBS payloadLength),
398+ extraHeaders =
399+ [ ("X-Amz-Date", awsTimeFormatBS $ spTimeStamp sp),
400+ ("x-amz-decoded-content-length", showBS payloadLength),
312401 ("content-length", showBS signedContentLength),
313402 ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD")
314403 ]
315− headersToSign = getHeadersToSign $ computedHeaders ++ streamingHeaders
316− signedHeaderKeys = B.intercalate ";" $ sort $ map fst headersToSign
317− finalQP = parseQuery (NC.queryString req)
404+ requestHeaders =
405+ addContentEncoding $
406+ foldr setHeader (NC.requestHeaders req) extraHeaders
407+
318408 -- 1. Compute Seed Signature
319409 -- 1.1 Canonical Request
320− canonicalReq =
321− mkCanonicalRequest
322− True
410+ (canonicalReq, signedHeaderKeys) =
411+ getCanonicalRequestAndSignedHeaders
412+ (IsStreamingLength payloadLength)
323413 sp
324− (NC.setQueryString finalQP req)
325− headersToSign
326− region = fromMaybe "" $ spRegion sp
327− scope = mkScope ts region
414+ req
415+ requestHeaders
416+
417+ scope = credentialScope sp
328418 accessKey = spAccessKey sp
329− secretKey = spSecretKey sp
330419 -- 1.2 String toSign
331420 stringToSign = mkStringToSign ts scope canonicalReq
332421 -- 1.3 Compute signature
333422 -- 1.3.1 compute signing key
334− signingKey = mkSigningKey ts region $ encodeUtf8 secretKey
423+ signingKey = getSigningKey sp
335424 -- 1.3.2 Compute signature
336425 seedSignature = computeSignature stringToSign signingKey
337426 -- 1.3.3 Compute Auth Header
338427 authHeader = mkAuthHeader accessKey scope signedHeaderKeys seedSignature
339428 -- 1.4 Updated headers for the request
340− finalReqHeaders = authHeader : (computedHeaders ++ streamingHeaders)
429+ finalReqHeaders = authHeader : requestHeaders
341430 -- headersToAdd = authHeader : datePair : streamingHeaders
342431
343432 toHexStr n = B8.pack $ printf "%x" n
@@ -407,3 +496,9 @@ signV4Stream !payloadLength !sp !req =
407496 NC.requestBodySource signedContentLength $
408497 src C..| signerConduit numParts lastPSize seedSignature
409498 }
499+
500+-- "setHeader r hdr" adds the hdr to r, replacing it in r if it already exists.
501+setHeader :: Header -> RequestHeaders -> RequestHeaders
502+setHeader hdr r =
503+ let r' = filter (\(name, _) -> name /= fst hdr) r
504+ in hdr : r'