Files
CxIDE/Services/AWSService.swift
T
cx-git-agent 1af1db7ffc Add comprehensive AWS integration (S3, Route53, CloudFront, Lambda, ACM) with 72 new tests
- AWSService: SigV4 request signing, XML parser, credential management
- AWSS3Service: bucket/object CRUD, website hosting, deploy pipeline
- AWSRoute53Service: hosted zones, DNS records, S3/CloudFront aliases
- AWSCloudFrontService: distributions, cache invalidation, ACM certs
- AWSLambdaService: functions, invoke, zip deploy
- AWSTools: 15 MCP agent tools for all AWS operations
- CredentialStore: AWS credential convenience properties
- AWSAndWebsiteTests: 72 tests covering models, XML parsing, errors,
  credentials, templates, deploy providers (165 total tests passing)
2026-04-21 19:40:51 -05:00

507 lines
18 KiB
Swift

// AWSService.swift
// CxIDE Core AWS client with Signature V4 authentication
//
// Implements AWS SigV4 request signing and provides the shared HTTP client
// for all AWS service integrations. Credentials are loaded from the
// encrypted CredentialStore never stored in source.
import Foundation
import CryptoKit
// MARK: - AWS Service (Core)
final class AWSService: @unchecked Sendable {
static let shared = AWSService()
private let credentialStore: CredentialStore
init(credentialStore: CredentialStore = .shared) {
self.credentialStore = credentialStore
}
// MARK: - Credential Access
var accessKeyId: String? { credentialStore.get("AWS_ACCESS_KEY_ID") }
var secretAccessKey: String? { credentialStore.get("AWS_SECRET_ACCESS_KEY") }
var sessionToken: String? { credentialStore.get("AWS_SESSION_TOKEN") }
var defaultRegion: String { credentialStore.get("AWS_DEFAULT_REGION") ?? "us-east-1" }
func setCredentials(accessKeyId: String, secretAccessKey: String, region: String = "us-east-1") {
credentialStore.set("AWS_ACCESS_KEY_ID", value: accessKeyId)
credentialStore.set("AWS_SECRET_ACCESS_KEY", value: secretAccessKey)
credentialStore.set("AWS_DEFAULT_REGION", value: region)
}
var isAuthenticated: Bool {
guard let k = accessKeyId, let s = secretAccessKey else { return false }
return !k.isEmpty && !s.isEmpty
}
// MARK: - AWS Regions
enum Region: String, CaseIterable, Sendable {
case usEast1 = "us-east-1"
case usEast2 = "us-east-2"
case usWest1 = "us-west-1"
case usWest2 = "us-west-2"
case euWest1 = "eu-west-1"
case euWest2 = "eu-west-2"
case euCentral1 = "eu-central-1"
case apSoutheast1 = "ap-southeast-1"
case apSoutheast2 = "ap-southeast-2"
case apNortheast1 = "ap-northeast-1"
}
// MARK: - Signed Request
/// Execute an AWS API request with SigV4 authentication
func request(
service: String,
region: String? = nil,
method: String = "GET",
path: String = "/",
query: [String: String] = [:],
headers: [String: String] = [:],
body: Data? = nil,
contentType: String = "application/json"
) async throws -> (data: Data, statusCode: Int) {
let region = region ?? defaultRegion
guard let accessKey = accessKeyId, let secretKey = secretAccessKey else {
throw AWSError.notAuthenticated
}
let host = "\(service).\(region).amazonaws.com"
let endpoint: String
if service == "s3" {
endpoint = "https://s3.\(region).amazonaws.com"
} else {
endpoint = "https://\(host)"
}
// Build URL with query parameters
var urlComponents = URLComponents(string: endpoint + path)!
if !query.isEmpty {
urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = urlComponents.url else {
throw AWSError.invalidURL(endpoint + path)
}
// Timestamp
let now = Date()
let dateFormatter = DateFormatter()
dateFormatter.timeZone = TimeZone(identifier: "UTC")
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
let amzDate = dateFormatter.string(from: now)
dateFormatter.dateFormat = "yyyyMMdd"
let dateStamp = dateFormatter.string(from: now)
// Payload hash
let payload = body ?? Data()
let payloadHash = SHA256.hash(data: payload).hexString
// Canonical query string (sorted)
let canonicalQueryString: String
if let queryItems = urlComponents.queryItems, !queryItems.isEmpty {
canonicalQueryString = queryItems
.sorted { $0.name < $1.name }
.map { "\(percentEncode($0.name))=\(percentEncode($0.value ?? ""))" }
.joined(separator: "&")
} else {
canonicalQueryString = ""
}
// Build headers
var signHeaders: [String: String] = [
"host": host,
"x-amz-date": amzDate,
"x-amz-content-sha256": payloadHash,
]
if let token = sessionToken {
signHeaders["x-amz-security-token"] = token
}
for (k, v) in headers {
signHeaders[k.lowercased()] = v
}
let signedHeaderNames = signHeaders.keys.sorted().joined(separator: ";")
let canonicalHeaders = signHeaders.keys.sorted()
.map { "\($0):\(signHeaders[$0]!.trimmingCharacters(in: .whitespaces))\n" }
.joined()
// Canonical request
let canonicalRequest = [
method,
uriEncode(path),
canonicalQueryString,
canonicalHeaders,
signedHeaderNames,
payloadHash,
].joined(separator: "\n")
let canonicalRequestHash = SHA256.hash(data: Data(canonicalRequest.utf8)).hexString
// String to sign
let credentialScope = "\(dateStamp)/\(region)/\(service)/aws4_request"
let stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
canonicalRequestHash,
].joined(separator: "\n")
// Signing key
let kDate = hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8))
let kRegion = hmacSHA256(key: kDate, data: Data(region.utf8))
let kService = hmacSHA256(key: kRegion, data: Data(service.utf8))
let kSigning = hmacSHA256(key: kService, data: Data("aws4_request".utf8))
// Signature
let signature = hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)).hexString
// Authorization header
let authorization = "AWS4-HMAC-SHA256 Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaderNames), Signature=\(signature)"
// Build URLRequest
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method
urlRequest.httpBody = body
urlRequest.timeoutInterval = 60
urlRequest.setValue(authorization, forHTTPHeaderField: "Authorization")
urlRequest.setValue(amzDate, forHTTPHeaderField: "x-amz-date")
urlRequest.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256")
urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
if let token = sessionToken {
urlRequest.setValue(token, forHTTPHeaderField: "x-amz-security-token")
}
for (k, v) in headers {
urlRequest.setValue(v, forHTTPHeaderField: k)
}
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw AWSError.invalidResponse
}
if httpResponse.statusCode >= 400 {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
throw AWSError.apiError(statusCode: httpResponse.statusCode, body: errorBody)
}
return (data, httpResponse.statusCode)
}
// MARK: - S3 Request (different host pattern)
/// Execute an S3 request (supports path-style and virtual-hosted-style)
func s3Request(
bucket: String? = nil,
key: String? = nil,
region: String? = nil,
method: String = "GET",
query: [String: String] = [:],
headers: [String: String] = [:],
body: Data? = nil,
contentType: String = "application/octet-stream"
) async throws -> (data: Data, statusCode: Int) {
let region = region ?? defaultRegion
guard let accessKey = accessKeyId, let secretKey = secretAccessKey else {
throw AWSError.notAuthenticated
}
// Build host and path (path-style addressing)
let host: String
let path: String
if let bucket = bucket {
host = "s3.\(region).amazonaws.com"
if let key = key {
path = "/\(bucket)/\(key)"
} else {
path = "/\(bucket)"
}
} else {
host = "s3.\(region).amazonaws.com"
path = "/"
}
let endpoint = "https://\(host)"
var urlComponents = URLComponents(string: endpoint + path)!
if !query.isEmpty {
urlComponents.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = urlComponents.url else {
throw AWSError.invalidURL(endpoint + path)
}
let now = Date()
let dateFormatter = DateFormatter()
dateFormatter.timeZone = TimeZone(identifier: "UTC")
dateFormatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
let amzDate = dateFormatter.string(from: now)
dateFormatter.dateFormat = "yyyyMMdd"
let dateStamp = dateFormatter.string(from: now)
let payload = body ?? Data()
let payloadHash = SHA256.hash(data: payload).hexString
let canonicalQueryString: String
if let qi = urlComponents.queryItems, !qi.isEmpty {
canonicalQueryString = qi.sorted { $0.name < $1.name }
.map { "\(percentEncode($0.name))=\(percentEncode($0.value ?? ""))" }
.joined(separator: "&")
} else {
canonicalQueryString = ""
}
var signHeaders: [String: String] = [
"host": host,
"x-amz-date": amzDate,
"x-amz-content-sha256": payloadHash,
]
if contentType != "application/octet-stream" || body != nil {
signHeaders["content-type"] = contentType
}
if let token = sessionToken {
signHeaders["x-amz-security-token"] = token
}
for (k, v) in headers {
signHeaders[k.lowercased()] = v
}
let signedHeaderNames = signHeaders.keys.sorted().joined(separator: ";")
let canonicalHeaders = signHeaders.keys.sorted()
.map { "\($0):\(signHeaders[$0]!.trimmingCharacters(in: .whitespaces))\n" }
.joined()
let canonicalRequest = [
method,
uriEncode(path),
canonicalQueryString,
canonicalHeaders,
signedHeaderNames,
payloadHash,
].joined(separator: "\n")
let canonicalRequestHash = SHA256.hash(data: Data(canonicalRequest.utf8)).hexString
let credentialScope = "\(dateStamp)/\(region)/s3/aws4_request"
let stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
canonicalRequestHash,
].joined(separator: "\n")
let kDate = hmacSHA256(key: Data("AWS4\(secretKey)".utf8), data: Data(dateStamp.utf8))
let kRegion = hmacSHA256(key: kDate, data: Data(region.utf8))
let kService = hmacSHA256(key: kRegion, data: Data("s3".utf8))
let kSigning = hmacSHA256(key: kService, data: Data("aws4_request".utf8))
let signature = hmacSHA256(key: kSigning, data: Data(stringToSign.utf8)).hexString
let authorization = "AWS4-HMAC-SHA256 Credential=\(accessKey)/\(credentialScope), SignedHeaders=\(signedHeaderNames), Signature=\(signature)"
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = method
urlRequest.httpBody = body
urlRequest.timeoutInterval = 120
urlRequest.setValue(authorization, forHTTPHeaderField: "Authorization")
urlRequest.setValue(amzDate, forHTTPHeaderField: "x-amz-date")
urlRequest.setValue(payloadHash, forHTTPHeaderField: "x-amz-content-sha256")
if contentType != "application/octet-stream" || body != nil {
urlRequest.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
if let token = sessionToken {
urlRequest.setValue(token, forHTTPHeaderField: "x-amz-security-token")
}
for (k, v) in headers {
urlRequest.setValue(v, forHTTPHeaderField: k)
}
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw AWSError.invalidResponse
}
if httpResponse.statusCode >= 400 {
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
throw AWSError.apiError(statusCode: httpResponse.statusCode, body: errorBody)
}
return (data, httpResponse.statusCode)
}
// MARK: - SigV4 Helpers
private func hmacSHA256(key: Data, data: Data) -> Data {
let hmac = HMAC<SHA256>.authenticationCode(for: data, using: SymmetricKey(data: key))
return Data(hmac)
}
private func percentEncode(_ string: String) -> String {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
return string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string
}
private func uriEncode(_ path: String) -> String {
// Encode each path segment individually (preserving /)
path.split(separator: "/", omittingEmptySubsequences: false)
.map { percentEncode(String($0)) }
.joined(separator: "/")
}
}
// MARK: - SHA256 Hex Extension
extension SHA256Digest {
var hexString: String {
self.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Data Hex Extension
extension Data {
var hexString: String {
self.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - AWS Error
enum AWSError: LocalizedError, Sendable {
case notAuthenticated
case invalidURL(String)
case invalidResponse
case apiError(statusCode: Int, body: String)
case bucketNotFound(String)
case objectNotFound(String)
case bucketAlreadyExists(String)
case accessDenied(String)
case invalidXML(String)
case deploymentFailed(String)
case distributionNotFound(String)
case hostedZoneNotFound(String)
case functionNotFound(String)
case timeout
var errorDescription: String? {
switch self {
case .notAuthenticated:
return "AWS credentials not configured. Use credential_set to store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."
case .invalidURL(let url):
return "Invalid AWS URL: \(url)"
case .invalidResponse:
return "Invalid response from AWS API"
case .apiError(let code, let body):
return "AWS API error (\(code)): \(body.prefix(500))"
case .bucketNotFound(let name):
return "S3 bucket not found: \(name)"
case .objectNotFound(let key):
return "S3 object not found: \(key)"
case .bucketAlreadyExists(let name):
return "S3 bucket already exists: \(name)"
case .accessDenied(let resource):
return "Access denied: \(resource)"
case .invalidXML(let detail):
return "Failed to parse AWS XML response: \(detail)"
case .deploymentFailed(let detail):
return "Deployment failed: \(detail)"
case .distributionNotFound(let id):
return "CloudFront distribution not found: \(id)"
case .hostedZoneNotFound(let name):
return "Route 53 hosted zone not found: \(name)"
case .functionNotFound(let name):
return "Lambda function not found: \(name)"
case .timeout:
return "AWS request timed out"
}
}
}
// MARK: - Simple XML Parser
/// Lightweight XML element for parsing AWS responses (no external dependencies)
final class AWSXMLElement {
let name: String
var text: String = ""
var children: [AWSXMLElement] = []
var attributes: [String: String] = [:]
init(name: String) { self.name = name }
func child(_ name: String) -> AWSXMLElement? {
children.first { $0.name == name }
}
func allChildren(_ name: String) -> [AWSXMLElement] {
children.filter { $0.name == name }
}
/// Recursively find first descendant with given name
func descendant(_ name: String) -> AWSXMLElement? {
for c in children {
if c.name == name { return c }
if let found = c.descendant(name) { return found }
}
return nil
}
func allDescendants(_ name: String) -> [AWSXMLElement] {
var result: [AWSXMLElement] = []
for c in children {
if c.name == name { result.append(c) }
result.append(contentsOf: c.allDescendants(name))
}
return result
}
}
/// Minimal XML parser using Foundation's XMLParser
final class AWSXMLParser: NSObject, XMLParserDelegate {
private var root: AWSXMLElement?
private var stack: [AWSXMLElement] = []
private var currentText = ""
func parse(data: Data) throws -> AWSXMLElement {
let parser = XMLParser(data: data)
parser.delegate = self
guard parser.parse(), let root = root else {
throw AWSError.invalidXML("Failed to parse XML response")
}
return root
}
func parserDidStartDocument(_ parser: XMLParser) {
stack = []
root = nil
}
func parser(_ parser: XMLParser, didStartElement elementName: String,
namespaceURI: String?, qualifiedName: String?,
attributes: [String: String] = [:]) {
let element = AWSXMLElement(name: elementName)
element.attributes = attributes
stack.last?.children.append(element)
stack.append(element)
currentText = ""
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
currentText += string
}
func parser(_ parser: XMLParser, didEndElement elementName: String,
namespaceURI: String?, qualifiedName: String?) {
if let current = stack.last {
current.text = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
}
if stack.count == 1 {
root = stack.first
}
stack.removeLast()
currentText = stack.last.flatMap { _ in "" } ?? ""
}
}