Skip to content

Commit 36d15bf

Browse files
authored
feat: Automatic clock skew adjustment (#2023)
1 parent 3b3b55f commit 36d15bf

File tree

16 files changed

+351
-23
lines changed

16 files changed

+351
-23
lines changed

AWSSDKSwiftCLI/Sources/AWSSDKSwiftCLI/Resources/Package.Base.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ private var runtimeTargets: [Target] {
130130
.SmithyIdentity,
131131
.SmithyRetriesAPI,
132132
.SmithyRetries,
133+
.SmithyTimestamps,
133134
.AWSSDKCommon,
134135
.AWSSDKHTTPAuth,
135136
.AWSSDKChecksums,

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,7 @@ private var runtimeTargets: [Target] {
574574
.SmithyIdentity,
575575
.SmithyRetriesAPI,
576576
.SmithyRetries,
577+
.SmithyTimestamps,
577578
.AWSSDKCommon,
578579
.AWSSDKHTTPAuth,
579580
.AWSSDKChecksums,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import struct Foundation.TimeInterval
9+
import typealias ClientRuntime.ClockSkewProvider
10+
import protocol ClientRuntime.ServiceError
11+
import class SmithyHTTPAPI.HTTPRequest
12+
import class SmithyHTTPAPI.HTTPResponse
13+
@_spi(SmithyTimestamps) import struct SmithyTimestamps.TimestampFormatter
14+
15+
public enum AWSClockSkewProvider {
16+
private static var absoluteThreshold: TimeInterval { 300.0 } // clock skew of < 5 minutes is not compensated
17+
private static var changeThreshold: TimeInterval { 60.0 } // changes to clock skew of < 1 minute are ignored
18+
19+
public static func provider() -> ClockSkewProvider<HTTPRequest, HTTPResponse> {
20+
return clockSkew(request:response:error:previous:)
21+
}
22+
23+
@Sendable
24+
private static func clockSkew(
25+
request: HTTPRequest,
26+
response: HTTPResponse,
27+
error: Error,
28+
previous: TimeInterval?
29+
) -> TimeInterval? {
30+
// Check if this error could be the result of clock skew.
31+
// If not, leave the current clock skew value unchanged.
32+
guard isAClockSkewError(request: request, error: error) else { return previous }
33+
34+
// Get the new clock skew value based on server & client times.
35+
let new = newClockSkew(request: request, response: response, error: error)
36+
37+
if let new, let previous {
38+
// Update clock skew if it's changed by at least the change threshold
39+
// Updating clock skew for insignificant changes in value will result
40+
// in retry when not likely to succeed
41+
return abs(new - previous) > changeThreshold ? new : previous
42+
} else {
43+
// If previous was nil but new is non-nil, return new.
44+
// If previous was non-nil but new is nil, return nil.
45+
// If previous and new are both nil, return nil.
46+
return new
47+
}
48+
}
49+
50+
private static func isAClockSkewError(request: HTTPRequest, error: Error) -> Bool {
51+
// Get the error code, which is a cue that clock skew is the cause of the error
52+
guard let code = (error as? ServiceError)?.errorCode else { return false }
53+
54+
// Check the error code to see if this error could be due to clock skew
55+
// If not, fail fast to prevent having to parse server datetime (slow)
56+
return isDefiniteClockSkewError(code: code) || isProbableClockSkewError(code: code, request: request)
57+
}
58+
59+
private static func isDefiniteClockSkewError(code: String) -> Bool {
60+
definiteClockSkewErrorCodes.contains(code)
61+
}
62+
63+
private static func isProbableClockSkewError(code: String, request: HTTPRequest) -> Bool {
64+
// Certain S3 HEAD methods will return generic HTTP 403 errors when the cause of the
65+
// failure is clock skew. To accommodate, check clock skew when the method is HEAD
66+
probableClockSkewErrorCodes.contains(code) || request.method == .head
67+
}
68+
69+
private static func newClockSkew(
70+
request: HTTPRequest,
71+
response: HTTPResponse,
72+
error: Error
73+
) -> TimeInterval? {
74+
// Get the datetime that the request was signed at.
75+
// If not available, clock skew can't be determined.
76+
// This should always be set when signing with sigv4 & sigv4a.
77+
guard let clientDate = request.signedAt else { return nil }
78+
79+
// Need a server Date (from the HTTP response headers) to calculate clock skew.
80+
// If not available, return no clock skew.
81+
// This header should always be included on AWS service responses.
82+
guard let httpDateString = response.headers.value(for: "Date") else { return nil }
83+
guard let serverDate = TimestampFormatter(format: .httpDate).date(from: httpDateString) else { return nil }
84+
85+
// Calculate & return clock skew if more than the threshold, else return nil.
86+
let clockSkew = serverDate.timeIntervalSince(clientDate)
87+
return abs(clockSkew) > absoluteThreshold ? clockSkew : nil
88+
}
89+
}
90+
91+
// These error codes indicate that the cause of the failure was clock skew.
92+
private let definiteClockSkewErrorCodes: Set = [
93+
"RequestTimeTooSkewed",
94+
"RequestExpired",
95+
"RequestInTheFuture",
96+
]
97+
98+
// These error codes indicate that a possible cause of the failure was clock skew.
99+
// So, when these are received, check/set clock skew & retry to see if that helps.
100+
private let probableClockSkewErrorCodes: Set = [
101+
"InvalidSignatureException",
102+
"AuthFailure",
103+
"SignatureDoesNotMatch",
104+
]

Sources/Core/AWSClientRuntime/Sources/AWSClientRuntime/Customizations/AuthTokenGenerator.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public class AuthTokenGenerator {
6565
requestBuilder.withQueryItem(URIQueryItem(name: "Action", value: "connect"))
6666
requestBuilder.withQueryItem(URIQueryItem(name: "DBUser", value: username))
6767

68+
let signedAt = Date()
69+
6870
let signingConfig = AWSSigningConfig(
6971
credentials: self.awsCredentialIdentity,
7072
expiration: expiration,
@@ -74,7 +76,7 @@ public class AuthTokenGenerator {
7476
shouldNormalizeURIPath: true,
7577
omitSessionToken: false
7678
),
77-
date: Date(),
79+
date: signedAt,
7880
service: "rds-db",
7981
region: region,
8082
signatureType: .requestQueryParams,
@@ -83,7 +85,8 @@ public class AuthTokenGenerator {
8385

8486
let signedRequest = await AWSSigV4Signer().sigV4SignedRequest(
8587
requestBuilder: requestBuilder,
86-
signingConfig: signingConfig
88+
signingConfig: signingConfig,
89+
signedAt: signedAt
8790
)
8891

8992
guard let presignedURL = signedRequest?.destination.url else {
@@ -119,6 +122,8 @@ public class AuthTokenGenerator {
119122
let actionQueryItemValue = isForAdmin ? "DbConnectAdmin" : "DbConnect"
120123
requestBuilder.withQueryItem(URIQueryItem(name: "Action", value: actionQueryItemValue))
121124

125+
let signedAt = Date()
126+
122127
let signingConfig = AWSSigningConfig(
123128
credentials: self.awsCredentialIdentity,
124129
expiration: expiration,
@@ -128,7 +133,7 @@ public class AuthTokenGenerator {
128133
shouldNormalizeURIPath: true,
129134
omitSessionToken: false
130135
),
131-
date: Date(),
136+
date: signedAt,
132137
service: "dsql",
133138
region: region,
134139
signatureType: .requestQueryParams,
@@ -137,7 +142,8 @@ public class AuthTokenGenerator {
137142

138143
let signedRequest = await AWSSigV4Signer().sigV4SignedRequest(
139144
requestBuilder: requestBuilder,
140-
signingConfig: signingConfig
145+
signingConfig: signingConfig,
146+
signedAt: signedAt
141147
)
142148

143149
guard let presignedURL = signedRequest?.destination.url else {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import AWSClientRuntime
9+
import XCTest
10+
import Smithy
11+
import ClientRuntime
12+
import SmithyHTTPAPI
13+
@_spi(SmithyTimestamps) import SmithyTimestamps
14+
15+
class AWSClockSkewProviderTests: XCTestCase {
16+
17+
// MARK: - nil previous clock skew
18+
19+
func test_clockSkewError_returnsNilWhenClientAndServerTimeAreTheSame() {
20+
let previousClockSkew: TimeInterval? = nil
21+
let client = "Sun, 02 Jan 2000 20:34:56.000 GMT"
22+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
23+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
24+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
25+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
26+
let error = ClockSkewTestError.definite
27+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
28+
}
29+
30+
func test_clockSkewError_returnsNilWhenClientAndServerTimeAreDifferentByLessThanThreshold() {
31+
let previousClockSkew: TimeInterval? = nil
32+
let client = "Sun, 02 Jan 2000 20:35:26.000 GMT" // +30 seconds
33+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
34+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
35+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
36+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
37+
let error = ClockSkewTestError.definite
38+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
39+
}
40+
41+
func test_clockSkewError_returnsIntervalWhenClientAndServerTimeAreDifferentByMoreThanThreshold() {
42+
let previousClockSkew: TimeInterval? = nil
43+
let client = "Sun, 02 Jan 2000 20:44:56.000 GMT" // server + 600 seconds
44+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
45+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
46+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
47+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
48+
let error = ClockSkewTestError.definite
49+
XCTAssertEqual(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew), -600.0)
50+
}
51+
52+
func test_nonClockSkewError_returnsNilWhenClientAndServerTimeAreTheSame() {
53+
let previousClockSkew: TimeInterval? = nil
54+
let client = "Sun, 02 Jan 2000 20:34:56.000 GMT"
55+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
56+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
57+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
58+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
59+
let error = ClockSkewTestError.notDueToClockSkew
60+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
61+
}
62+
63+
func test_nonClockSkewError_returnsNilWhenClientAndServerTimeAreDifferentByLessThanThreshold() {
64+
let previousClockSkew: TimeInterval? = nil
65+
let client = "Sun, 02 Jan 2000 20:35:26.000 GMT" // +30 seconds
66+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
67+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
68+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
69+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
70+
let error = ClockSkewTestError.notDueToClockSkew
71+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
72+
}
73+
74+
func test_nonClockSkewError_returnsNilWhenClientAndServerTimeAreDifferentByMoreThanThreshold() {
75+
let previousClockSkew: TimeInterval? = nil
76+
let client = "Sun, 02 Jan 2000 20:36:26.000 GMT" // +90 seconds
77+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
78+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
79+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
80+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
81+
let error = ClockSkewTestError.notDueToClockSkew
82+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
83+
}
84+
85+
func test_headRequest_returnsNilWhenClientAndServerTimeAreTheSame() {
86+
let previousClockSkew: TimeInterval? = nil
87+
let client = "Sun, 02 Jan 2000 20:34:56.000 GMT"
88+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
89+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
90+
let request = HTTPRequestBuilder().withMethod(.head).withSignedAt(clientDate).build()
91+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
92+
let error = ClockSkewTestError.notDueToClockSkew
93+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
94+
}
95+
96+
func test_headRequest_returnsNilWhenClientAndServerTimeAreDifferentByLessThanThreshold() {
97+
let previousClockSkew: TimeInterval? = nil
98+
let client = "Sun, 02 Jan 2000 20:35:26.000 GMT" // +30 seconds
99+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
100+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
101+
let request = HTTPRequestBuilder().withMethod(.head).withSignedAt(clientDate).build()
102+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
103+
let error = ClockSkewTestError.notDueToClockSkew
104+
XCTAssertNil(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew))
105+
}
106+
107+
func test_headRequest_returnsIntervalWhenClientAndServerTimeAreDifferentByMoreThanThreshold() {
108+
let previousClockSkew: TimeInterval? = nil
109+
let client = "Sun, 02 Jan 2000 20:44:56.000 GMT" // server + 600 seconds
110+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
111+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
112+
let request = HTTPRequestBuilder().withMethod(.head).withSignedAt(clientDate).build()
113+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
114+
let error = ClockSkewTestError.notDueToClockSkew
115+
XCTAssertEqual(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew), -600.0)
116+
}
117+
118+
// MARK: - Non-nil previous clock skew
119+
120+
func test_nonNilPrevious_returnsNilWhenNewClockSkewIsNil() {
121+
let previousClockSkew: TimeInterval = -400.0
122+
let client = "Sun, 02 Jan 2000 20:34:56.000 GMT" // server + 0 seconds
123+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
124+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
125+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
126+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
127+
let error = ClockSkewTestError.definite
128+
XCTAssertEqual(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew), nil)
129+
}
130+
131+
func test_nonNilPrevious_returnsPreviousWhenClientAndServerTimeAreDifferentByLessThanThreshold() {
132+
let previousClockSkew: TimeInterval = -400.0
133+
let client = "Sun, 02 Jan 2000 20:40:56.000 GMT" // server + 360 seconds
134+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
135+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
136+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
137+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
138+
let error = ClockSkewTestError.definite
139+
XCTAssertEqual(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew), previousClockSkew)
140+
}
141+
142+
func test_nonNilPrevious_returnsNewWhenClientAndServerTimeAreDifferentByMoreThanThreshold() {
143+
let previousClockSkew: TimeInterval = -400.0
144+
let client = "Sun, 02 Jan 2000 20:44:56.000 GMT" // server + 600 seconds
145+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
146+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
147+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
148+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
149+
let error = ClockSkewTestError.definite
150+
XCTAssertEqual(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew), -600.0)
151+
}
152+
153+
func test_nonNilPrevious_returnsPreviousWhenErrorIsNotAClockSkewError() {
154+
let previousClockSkew: TimeInterval = -400.0
155+
let client = "Sun, 02 Jan 2000 20:44:56.000 GMT" // server + 600 seconds
156+
let server = "Sun, 02 Jan 2000 20:34:56.000 GMT"
157+
let clientDate = TimestampFormatter(format: .httpDate).date(from: client)!
158+
let request = HTTPRequestBuilder().withMethod(.get).withSignedAt(clientDate).build()
159+
let response = HTTPResponse(headers: Headers(["Date": server]), body: ByteStream.noStream, statusCode: .badRequest)
160+
let error = ClockSkewTestError.notDueToClockSkew
161+
XCTAssertEqual(AWSClockSkewProvider.provider()(request, response, error, previousClockSkew), previousClockSkew)
162+
}
163+
}
164+
165+
private struct ClockSkewTestError: Error, ServiceError {
166+
static var definite: Self { .init(typeName: "RequestTimeTooSkewed") }
167+
static var notDueToClockSkew: Self { .init(typeName: "NotAClockSkewError") }
168+
169+
var typeName: String?
170+
var message: String? { "" }
171+
}

Sources/Core/AWSClientRuntime/Tests/AWSClientRuntimeTests/Retry/AWSRetryIntegrationTests.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@ final class RetryIntegrationTests: XCTestCase {
5757
.attributes(context)
5858
.retryErrorInfoProvider(DefaultRetryErrorInfoProvider.errorInfo(for:))
5959
.retryStrategy(subject)
60-
.deserialize({ _, _ in TestOutputResponse() })
60+
.deserialize({ response, _ in
61+
if response.statusCode == .ok {
62+
return TestOutputResponse()
63+
} else {
64+
throw TestHTTPError(statusCode: response.statusCode)
65+
}
66+
})
6167
.executeRequest(next)
6268
builder.interceptors.add(AmzSdkInvocationIdMiddleware())
6369
builder.interceptors.add(AmzSdkRequestMiddleware(maxRetries: subject.options.maxRetriesBase))
@@ -229,9 +235,10 @@ private class TestOutputHandler: ExecuteRequest {
229235
// Return either a successful response or a HTTP error, depending on the directions in the test step.
230236
switch testStep.response {
231237
case .success:
232-
return HTTPResponse()
238+
return HTTPResponse(statusCode: .ok)
233239
case .httpError(let statusCode):
234-
throw TestHTTPError(statusCode: statusCode)
240+
let httpStatusCode = HTTPStatusCode(rawValue: statusCode)!
241+
return HTTPResponse(statusCode: httpStatusCode)
235242
}
236243
}
237244

@@ -310,9 +317,8 @@ private class TestOutputHandler: ExecuteRequest {
310317
private struct TestHTTPError: HTTPError, Error {
311318
var httpResponse: HTTPResponse
312319

313-
init(statusCode: Int) {
314-
guard let statusCodeValue = HTTPStatusCode(rawValue: statusCode) else { fatalError("Unrecognized HTTP code") }
315-
self.httpResponse = HTTPResponse(statusCode: statusCodeValue)
320+
init(statusCode: HTTPStatusCode) {
321+
self.httpResponse = HTTPResponse(statusCode: statusCode)
316322
}
317323
}
318324

0 commit comments

Comments
 (0)