Overview
AWSV4Signer implements AWS Signature Version 4 signing for S3-compatible APIs. This service signs HTTP requests with HMAC-SHA256 authentication, enabling secure access to Cloudflare R2 storage.
Located at: Fiaxe/Services/AWSV4Signer.swift:6
Type Definition
nonisolated enum AWSV4Signer
Implemented as a nonisolated enum to allow calling from any actor context without isolation restrictions.
Methods
sign()
Signs a URLRequest with AWS Signature V4 and returns the signed request.
static func sign(
request: URLRequest,
credentials: R2Credentials,
payloadHash: String = "UNSIGNED-PAYLOAD",
date: Date = Date()
) -> URLRequest
Request with url, httpMethod, and headers already set. This request will be modified with authentication headers.
R2 credentials containing:
- Account ID
- Access key ID
- Secret access key
- Bucket name
payloadHash
String
default:"UNSIGNED-PAYLOAD"
SHA-256 hex digest of the request body. Use:
"UNSIGNED-PAYLOAD" for streaming uploads or when the payload isn’t pre-hashed
AWSV4Signer.sha256Hex("") for empty body (GET, DELETE, HEAD requests)
AWSV4Signer.sha256Hex(bodyContent) for requests with a body
Signing date. Defaults to current time. Must match the date in x-amz-date header.
Returns: Signed URLRequest with Authorization, x-amz-date, x-amz-content-sha256, and Host headers.
Implementation Details
The signing process follows the AWS Signature Version 4 specification:
- Add required headers
// Example from AWSV4Signer.swift:24-33
let amzDate = amzDateString(from: date) // Format: "20240315T123045Z"
let shortDate = shortDateString(from: date) // Format: "20240315"
let credentialScope = "\(shortDate)/auto/s3/aws4_request"
request.setValue(amzDate, forHTTPHeaderField: "x-amz-date")
request.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256")
if let host = request.url?.host {
request.setValue(host, forHTTPHeaderField: "Host")
}
- Build canonical request
// Example from AWSV4Signer.swift:36-57
let httpMethod = request.httpMethod ?? "PUT"
let canonicalURI = canonicalPath(from: request.url)
let canonicalQS = canonicalQueryString(from: request.url)
let allHeaders = request.allHTTPHeaderFields ?? [:]
let sortedLowercasedKeys = allHeaders.keys.map { $0.lowercased() }.sorted()
let signedHeaders = sortedLowercasedKeys.joined(separator: ";")
let canonicalHeaders = sortedLowercasedKeys
.map { key -> String in
let value = allHeaders.first { $0.key.lowercased() == key }?.value ?? ""
return "\(key):\(value.trimmingCharacters(in: .whitespaces))"
}
.joined(separator: "\n")
let canonicalRequest = [
httpMethod,
canonicalURI,
canonicalQS,
canonicalHeaders + "\n",
signedHeaders,
payloadHash
].joined(separator: "\n")
- Create string to sign
// Example from AWSV4Signer.swift:59-66
let canonicalRequestHash = sha256Hex(canonicalRequest)
let stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
canonicalRequestHash
].joined(separator: "\n")
- Derive signing key and compute signature
// Example from AWSV4Signer.swift:68-76
let signingKey = deriveSigningKey(
secret: credentials.secretAccessKey,
date: shortDate,
region: "auto", // R2 uses "auto" region
service: "s3"
)
let signature = hmacHex(key: signingKey, data: Data(stringToSign.utf8))
let authorization = "AWS4-HMAC-SHA256 Credential=\(credentials.accessKeyId)/\(credentialScope), SignedHeaders=\(signedHeaders), Signature=\(signature)"
request.setValue(authorization, forHTTPHeaderField: "Authorization")
presignedURL()
Generates a presigned GET URL valid for a specified duration.
static func presignedURL(
for key: String,
credentials: R2Credentials,
expiresIn: Int = 3600,
date: Date = Date()
) -> URL?
Object key in the bucket. Example: "photos/vacation.jpg"
R2 credentials for signing the URL.
Number of seconds the URL should remain valid. Default is 1 hour (3600 seconds).
Signing date. The expiration is calculated from this date.
Returns: Presigned URL that can be fetched without auth headers, or nil if URL construction fails.
Implementation Details
Presigned URLs embed authentication in query parameters instead of headers:
// Example from AWSV4Signer.swift:146-201
let region = "auto"
let service = "s3"
let amzDate = amzDateString(from: date)
let shortDate = shortDateString(from: date)
let credentialScope = "\(shortDate)/\(region)/\(service)/aws4_request"
// Build URL with encoded key
var comps = URLComponents()
comps.scheme = "https"
comps.host = "\(credentials.accountId).r2.cloudflarestorage.com"
comps.percentEncodedPath = "/\(credentials.bucketName)/\(encodedKey)"
// Add presign query parameters (must be sorted)
comps.queryItems = [
URLQueryItem(name: "X-Amz-Algorithm", value: "AWS4-HMAC-SHA256"),
URLQueryItem(name: "X-Amz-Credential", value: "\(credentials.accessKeyId)/\(credentialScope)"),
URLQueryItem(name: "X-Amz-Date", value: amzDate),
URLQueryItem(name: "X-Amz-Expires", value: "\(expiresIn)"),
URLQueryItem(name: "X-Amz-SignedHeaders", value: "host"),
]
// Create canonical request and signature
let canonicalRequest = [
"GET", canonicalURI, canonicalQS,
"host:\(host)\n", "host", "UNSIGNED-PAYLOAD"
].joined(separator: "\n")
let stringToSign = [
"AWS4-HMAC-SHA256", amzDate, credentialScope, sha256Hex(canonicalRequest)
].joined(separator: "\n")
let signingKey = deriveSigningKey(...)
let signature = hmacHex(key: signingKey, data: Data(stringToSign.utf8))
// Append signature to query parameters
comps.queryItems?.append(URLQueryItem(name: "X-Amz-Signature", value: signature))
return comps.url
sha256Hex()
Computes SHA-256 hash of a string and returns hex-encoded result.
static func sha256Hex(_ string: String) -> String
Returns: Lowercase hex-encoded SHA-256 digest.
// Example from AWSV4Signer.swift:203-206
let digest = SHA256.hash(data: Data(string.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
Helper Methods
URI Encoding
The signer uses strict URI encoding that matches AWS requirements:
private static func uriEncode(_ string: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
}
Only alphanumerics and -._~ are left unencoded, matching AWS Signature V4 specifications.
Canonical Path
The canonical path preserves trailing slashes and applies strict encoding:
// Example from AWSV4Signer.swift:101-126
private static func canonicalPath(from url: URL?) -> String {
guard let url else { return "/" }
// Extract raw percent-encoded path to preserve trailing slashes
let rawPath: String
if let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
!comps.percentEncodedPath.isEmpty {
rawPath = comps.percentEncodedPath
} else {
rawPath = url.path
}
guard !rawPath.isEmpty else { return "/" }
// Decode each segment then re-encode with strict AWS URI-encode
let hasTrailing = rawPath.hasSuffix("/")
let segments = rawPath.components(separatedBy: "/")
let encoded = segments
.map { seg -> String in
let decoded = seg.removingPercentEncoding ?? seg
return uriEncode(decoded)
}
.joined(separator: "/")
// Restore trailing slash if present
return hasTrailing && !encoded.hasSuffix("/") ? encoded + "/" : encoded
}
This is crucial for R2 folder operations where trailing slashes distinguish folders from files.
Canonical Query String
Query parameters are sorted and strictly encoded:
// Example from AWSV4Signer.swift:128-136
private static func canonicalQueryString(from url: URL?) -> String {
guard let url,
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let items = comps.queryItems, !items.isEmpty else { return "" }
return items
.sorted { $0.name < $1.name }
.map { "\(uriEncode($0.name))=\(uriEncode($0.value ?? ""))" }
.joined(separator: "&")
}
Signing Key Derivation
The signing key is derived through multiple HMAC operations:
// Example from AWSV4Signer.swift:208-214
private static func deriveSigningKey(
secret: String,
date: String,
region: String,
service: String
) -> SymmetricKey {
let kSecret = SymmetricKey(data: Data(("AWS4" + secret).utf8))
let kDate = hmac(key: kSecret, data: Data(date.utf8))
let kRegion = hmac(key: kDate, data: Data(region.utf8))
let kService = hmac(key: kRegion, data: Data(service.utf8))
return hmac(key: kService, data: Data("aws4_request".utf8))
}
This creates a key hierarchy: kSecret -> kDate -> kRegion -> kService -> kSigning
Two date formats are used:
// AMZ date format: "20240315T123045Z"
private static func amzDateString(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.locale = Locale(identifier: "en_US_POSIX")
return fmt.string(from: date)
}
// Short date format: "20240315"
private static func shortDateString(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "yyyyMMdd"
fmt.timeZone = TimeZone(identifier: "UTC")
fmt.locale = Locale(identifier: "en_US_POSIX")
return fmt.string(from: date)
}
Usage Examples
let credentials = R2Credentials(
accountId: "your-account-id",
accessKeyId: "your-access-key",
secretAccessKey: "your-secret-key",
bucketName: "my-bucket"
)
// Sign a PUT request for uploading
var uploadRequest = URLRequest(url: uploadURL)
uploadRequest.httpMethod = "PUT"
uploadRequest.setValue("image/jpeg", forHTTPHeaderField: "Content-Type")
let signedRequest = AWSV4Signer.sign(
request: uploadRequest,
credentials: credentials,
payloadHash: "UNSIGNED-PAYLOAD" // For streaming uploads
)
// Sign a GET request
var getRequest = URLRequest(url: objectURL)
getRequest.httpMethod = "GET"
let signedGet = AWSV4Signer.sign(
request: getRequest,
credentials: credentials,
payloadHash: AWSV4Signer.sha256Hex("") // Empty body for GET
)
// Generate a presigned URL (no authentication headers needed)
if let presignedURL = AWSV4Signer.presignedURL(
for: "photos/vacation.jpg",
credentials: credentials,
expiresIn: 3600 // Valid for 1 hour
) {
// Share this URL with others - it works without credentials
print("Download URL: \(presignedURL)")
// Fetch using a regular HTTP GET
let (data, _) = try await URLSession.shared.data(from: presignedURL)
}
// Sign a DELETE request
var deleteRequest = URLRequest(url: objectURL)
deleteRequest.httpMethod = "DELETE"
let signedDelete = AWSV4Signer.sign(
request: deleteRequest,
credentials: credentials,
payloadHash: AWSV4Signer.sha256Hex("") // Empty body
)
AWS Signature V4 Specification
The implementation follows the official AWS Signature Version 4 specification:
Canonical Request
Normalize the HTTP request into a canonical format:HTTPMethod\n
CanonicalURI\n
CanonicalQueryString\n
CanonicalHeaders\n
SignedHeaders\n
HashedPayload
String to Sign
Create a string that includes the algorithm, timestamp, credential scope, and hashed canonical request:AWS4-HMAC-SHA256\n
Timestamp\n
CredentialScope\n
HashedCanonicalRequest
Signing Key
Derive a signing key through a series of HMAC operations:kSecret = HMAC("AWS4" + SecretKey, Date)
kRegion = HMAC(kSecret, Region)
kService = HMAC(kRegion, Service)
kSigning = HMAC(kService, "aws4_request")
Signature
Calculate the signature by HMAC-SHA256 of the string to sign with the signing key:Signature = HMAC-SHA256(kSigning, StringToSign)
Authorization Header
Add the Authorization header to the request:AWS4-HMAC-SHA256 Credential=AccessKeyId/CredentialScope,
SignedHeaders=SignedHeaders,
Signature=Signature
Cloudflare R2 Specifics
R2 uses the region "auto" instead of standard AWS regions like "us-east-1". The endpoint format is:https://{accountId}.r2.cloudflarestorage.com/{bucketName}/{key}