├── .github ├── FUNDING.yml └── workflows │ └── swift.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── URLRequest+AWS │ └── URLRequest+AWS.swift └── Tests ├── LinuxMain.swift └── URLRequest+AWSTests ├── URLRequest+AWSTests.swift └── XCTestManifests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: robb 2 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: macOS-latest 8 | 9 | steps: 10 | - name: Select Xcode 11 | run: sudo xcode-select -switch /Applications/Xcode_11.1.app 12 | - uses: actions/checkout@v1 13 | - name: Build 14 | run: swift build -v 15 | - name: Run tests 16 | run: swift test -v 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Robert Böhnke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "URLRequest+AWS", 7 | platforms: [ 8 | .iOS(.v13), 9 | .macOS(.v10_15) 10 | ], 11 | products: [ 12 | .library( 13 | name: "URLRequest+AWS", 14 | targets: ["URLRequest+AWS"]), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "URLRequest+AWS", 19 | dependencies: []), 20 | .testTarget( 21 | name: "URLRequest+AWSTests", 22 | dependencies: ["URLRequest+AWS"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # URLRequest+AWS 2 | 3 | An extension on `URLRequest` to sign it for AWS. 4 | 5 | ## Example Usage 6 | 7 | ```swift 8 | var request = URLRequest(url: URL(string: "https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08")!) 9 | 10 | request.sign(credentials: credentials, date: date, region: "us-east-1", service: "iam") 11 | ``` 12 | -------------------------------------------------------------------------------- /Sources/URLRequest+AWS/URLRequest+AWS.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | 4 | public struct Credentials: Codable { 5 | public let accessKey: String 6 | 7 | public let accessKeyID: String 8 | 9 | fileprivate var prefixedKey: String { 10 | "AWS4" + accessKey 11 | } 12 | } 13 | 14 | public extension URLRequest { 15 | mutating func sign(credentials: Credentials, date now: Date = Date(), region: String, service: String) { 16 | precondition(httpBodyStream == nil, "Streaming reqeuests using `httpBodyStream` are not supported.") 17 | 18 | let date = dateTimeFormatter.string(from: now) 19 | 20 | setValue(date, forHTTPHeaderField: "x-amz-date") 21 | setValue(nil, forHTTPHeaderField: "Authorization") 22 | setValue(nil, forHTTPHeaderField: "x-amz-content-sha256") 23 | setValue(url?.host, forHTTPHeaderField: "host") 24 | 25 | let parameters = [ 26 | dayFormatter.string(from: now), 27 | region, 28 | service, 29 | "aws4_request" 30 | ] 31 | 32 | let derivedSigningKey = parameters 33 | .reduce(into: SymmetricKey(data: Data(credentials.prefixedKey.utf8))) { key, parameter in 34 | var hmac = HMAC(key: key) 35 | hmac.update(data: Data(parameter.utf8)) 36 | 37 | key = SymmetricKey(data: Data(hmac.finalize())) 38 | } 39 | 40 | let credentialScope = parameters.joined(separator: "/") 41 | 42 | let canonicalHeaders = allHTTPHeaderFields? 43 | .mapValues { value in 44 | value 45 | .trimmingCharacters(in: .whitespaces) 46 | .split(separator: " ") 47 | .joined(separator: " ") 48 | } 49 | .map { key, value in 50 | key.lowercased() + ":" + value 51 | } 52 | .sorted() 53 | .joined(separator: "\n") 54 | .appending("\n") 55 | 56 | let signedHeaders = allHTTPHeaderFields? 57 | .keys 58 | .sorted() 59 | .joined(separator: ";") 60 | .lowercased() 61 | 62 | let encodedPayloadSignature = SHA256.hash(data: httpBody ?? Data()).hexEncodedString() 63 | 64 | let canonicalRequestComponents = [ 65 | httpMethod ?? "GET", "\n", 66 | url?.canonicalURI ?? "", "\n", 67 | url?.canonicalQueryString ?? "", "\n", 68 | canonicalHeaders ?? "", "\n", 69 | signedHeaders ?? "", "\n", 70 | encodedPayloadSignature 71 | ] 72 | 73 | let requestSignature = canonicalRequestComponents 74 | .reduce(into: SHA256()) { f, parameter in 75 | f.update(data: Data(parameter.utf8)) 76 | } 77 | .finalize() 78 | 79 | let signatureComponents = [ 80 | "AWS4-HMAC-SHA256", "\n", 81 | date, "\n", 82 | credentialScope, "\n", 83 | requestSignature.hexEncodedString() 84 | ] 85 | 86 | let signature = signatureComponents 87 | .reduce(into: HMAC(key: derivedSigningKey)) { f, component in 88 | f.update(data: Data(component.utf8)) 89 | } 90 | .finalize() 91 | 92 | let authorization = "AWS4-HMAC-SHA256 Credential=\(credentials.accessKeyID)/\(credentialScope), SignedHeaders=\(signedHeaders ?? ""), Signature=\(signature.hexEncodedString())" 93 | 94 | setValue(authorization, forHTTPHeaderField: "Authorization") 95 | setValue(encodedPayloadSignature, forHTTPHeaderField: "x-amz-content-sha256") 96 | } 97 | } 98 | 99 | private let urlPathAllowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "@")) 100 | 101 | private extension URL { 102 | var canonicalURI: String? { 103 | path.addingPercentEncoding(withAllowedCharacters: urlPathAllowed) 104 | } 105 | 106 | var canonicalQueryString: String? { 107 | query? 108 | .split(separator: "&") 109 | .sorted() 110 | .joined(separator: "&") 111 | } 112 | } 113 | 114 | private extension Sequence where Element == UInt8 { 115 | func hexEncodedString() -> String { 116 | map { String(format: "%02x", $0) }.joined() 117 | } 118 | } 119 | 120 | private let dateTimeFormatter: DateFormatter = { 121 | let formatter = DateFormatter() 122 | formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" 123 | formatter.locale = Locale(identifier: "en_US_POSIX") 124 | formatter.timeZone = TimeZone(abbreviation: "UTC") 125 | 126 | return formatter 127 | }() 128 | 129 | private let dayFormatter: DateFormatter = { 130 | let formatter = DateFormatter() 131 | formatter.dateFormat = "yyyyMMdd" 132 | formatter.locale = Locale(identifier: "en_US_POSIX") 133 | formatter.timeZone = TimeZone(abbreviation: "UTC") 134 | 135 | return formatter 136 | }() 137 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import URLRequest_AWSTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += URLRequest_AWSTests.__allTests() 7 | 8 | XCTMain(tests) 9 | -------------------------------------------------------------------------------- /Tests/URLRequest+AWSTests/URLRequest+AWSTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import URLRequest_AWS 3 | 4 | final class URLRequest_AWSTests: XCTestCase { 5 | let credentials = Credentials( 6 | accessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", 7 | accessKeyID: "AKIDEXAMPLE" 8 | ) 9 | 10 | let date = Date(timeIntervalSince1970: 1440938160) 11 | 12 | /// Example request per https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html 13 | /// 14 | /// ``` 15 | /// GET https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08 HTTP/1.1 16 | /// Host: iam.amazonaws.com 17 | /// Content-Type: application/x-www-form-urlencoded; charset=utf-8 18 | /// X-Amz-Date: 20150830T123600Z 19 | /// 20 | /// 21 | /// ``` 22 | func testCanonicalRequest() { 23 | var request = URLRequest(url: URL(string: "https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08")!) 24 | request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type") 25 | 26 | // Set an authorization header to test it not affecting the outcome, 27 | // even if we will override it. 28 | request.setValue("meh", forHTTPHeaderField: "Authorization") 29 | 30 | request.httpBody = "".data(using: .utf8) 31 | 32 | request.sign(credentials: credentials, date: date, region: "us-east-1", service: "iam") 33 | 34 | XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7") 35 | 36 | // Signing should be idempotent. 37 | request.sign(credentials: credentials, date: date, region: "us-east-1", service: "iam") 38 | 39 | XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date, Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/URLRequest+AWSTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension URLRequest_AWSTests { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__URLRequest_AWSTests = [ 9 | ("testCanonicalRequest", testCanonicalRequest), 10 | ] 11 | } 12 | 13 | public func __allTests() -> [XCTestCaseEntry] { 14 | return [ 15 | testCase(URLRequest_AWSTests.__allTests__URLRequest_AWSTests), 16 | ] 17 | } 18 | #endif 19 | --------------------------------------------------------------------------------