1af1db7ffc
- 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)
168 lines
5.3 KiB
Swift
168 lines
5.3 KiB
Swift
// CredentialStore.swift
|
|
// CxIDE — Encrypted .env file credential management
|
|
//
|
|
// Stores API keys and secrets in an AES-256 encrypted .env file
|
|
// within the workspace. Never stores credentials in plain text in source.
|
|
|
|
import Foundation
|
|
import CryptoKit
|
|
|
|
// MARK: - Credential Store
|
|
|
|
final class CredentialStore: @unchecked Sendable {
|
|
static let shared = CredentialStore()
|
|
|
|
private let fileName = ".cxide-credentials.enc"
|
|
private var cachedCredentials: [String: String] = [:]
|
|
private let lock = NSLock()
|
|
|
|
// Derive encryption key from machine-specific seed + app bundle ID
|
|
private var encryptionKey: SymmetricKey {
|
|
let seed = ProcessInfo.processInfo.hostName
|
|
+ (Bundle.main.bundleIdentifier ?? "com.cxide.CxIDE")
|
|
+ NSUserName()
|
|
let hash = SHA256.hash(data: Data(seed.utf8))
|
|
return SymmetricKey(data: hash)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Load credentials from encrypted file in the given directory
|
|
func load(from directory: URL) throws {
|
|
let fileURL = directory.appendingPathComponent(fileName)
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
lock.lock()
|
|
cachedCredentials = [:]
|
|
lock.unlock()
|
|
return
|
|
}
|
|
|
|
let encryptedData = try Data(contentsOf: fileURL)
|
|
let sealedBox = try AES.GCM.SealedBox(combined: encryptedData)
|
|
let decrypted = try AES.GCM.open(sealedBox, using: encryptionKey)
|
|
let json = try JSONSerialization.jsonObject(with: decrypted) as? [String: String] ?? [:]
|
|
|
|
lock.lock()
|
|
cachedCredentials = json
|
|
lock.unlock()
|
|
}
|
|
|
|
/// Save current credentials to encrypted file
|
|
func save(to directory: URL) throws {
|
|
let fileURL = directory.appendingPathComponent(fileName)
|
|
|
|
lock.lock()
|
|
let creds = cachedCredentials
|
|
lock.unlock()
|
|
|
|
let json = try JSONSerialization.data(withJSONObject: creds, options: .sortedKeys)
|
|
let sealedBox = try AES.GCM.seal(json, using: encryptionKey)
|
|
guard let combined = sealedBox.combined else {
|
|
throw CredentialError.encryptionFailed
|
|
}
|
|
try combined.write(to: fileURL)
|
|
|
|
// Set restrictive permissions (owner read/write only)
|
|
try FileManager.default.setAttributes(
|
|
[.posixPermissions: 0o600],
|
|
ofItemAtPath: fileURL.path
|
|
)
|
|
}
|
|
|
|
/// Get a credential value
|
|
func get(_ key: String) -> String? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return cachedCredentials[key]
|
|
}
|
|
|
|
/// Set a credential value
|
|
func set(_ key: String, value: String) {
|
|
lock.lock()
|
|
cachedCredentials[key] = value
|
|
lock.unlock()
|
|
}
|
|
|
|
/// Remove a credential
|
|
func remove(_ key: String) {
|
|
lock.lock()
|
|
cachedCredentials.removeValue(forKey: key)
|
|
lock.unlock()
|
|
}
|
|
|
|
/// List all credential keys (not values)
|
|
func keys() -> [String] {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return Array(cachedCredentials.keys).sorted()
|
|
}
|
|
|
|
/// Check if a credential exists
|
|
func has(_ key: String) -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return cachedCredentials[key] != nil
|
|
}
|
|
|
|
/// Remove the encrypted file
|
|
func deleteStore(in directory: URL) throws {
|
|
let fileURL = directory.appendingPathComponent(fileName)
|
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
|
try FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
lock.lock()
|
|
cachedCredentials = [:]
|
|
lock.unlock()
|
|
}
|
|
|
|
// MARK: - Convenience: GoDaddy
|
|
|
|
/// GoDaddy OTE (test environment) credentials
|
|
var godaddyOTEKey: String? { self.get("GODADDY_OTE_KEY") }
|
|
var godaddyOTESecret: String? { self.get("GODADDY_OTE_SECRET") }
|
|
|
|
/// GoDaddy Production credentials
|
|
var godaddyProdKey: String? { self.get("GODADDY_PROD_KEY") }
|
|
var godaddyProdSecret: String? { self.get("GODADDY_PROD_SECRET") }
|
|
|
|
/// Set GoDaddy OTE credentials
|
|
func setGoDaddyOTE(key: String, secret: String) {
|
|
set("GODADDY_OTE_KEY", value: key)
|
|
set("GODADDY_OTE_SECRET", value: secret)
|
|
}
|
|
|
|
/// Set GoDaddy Production credentials
|
|
func setGoDaddyProduction(key: String, secret: String) {
|
|
set("GODADDY_PROD_KEY", value: key)
|
|
set("GODADDY_PROD_SECRET", value: secret)
|
|
}
|
|
|
|
// MARK: - Convenience: AWS
|
|
|
|
var awsAccessKeyId: String? { self.get("AWS_ACCESS_KEY_ID") }
|
|
var awsSecretAccessKey: String? { self.get("AWS_SECRET_ACCESS_KEY") }
|
|
var awsDefaultRegion: String { self.get("AWS_DEFAULT_REGION") ?? "us-east-1" }
|
|
|
|
func setAWS(accessKeyId: String, secretAccessKey: String, region: String = "us-east-1") {
|
|
set("AWS_ACCESS_KEY_ID", value: accessKeyId)
|
|
set("AWS_SECRET_ACCESS_KEY", value: secretAccessKey)
|
|
set("AWS_DEFAULT_REGION", value: region)
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum CredentialError: LocalizedError {
|
|
case encryptionFailed
|
|
case decryptionFailed
|
|
case invalidFormat
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .encryptionFailed: return "Failed to encrypt credentials"
|
|
case .decryptionFailed: return "Failed to decrypt credentials"
|
|
case .invalidFormat: return "Invalid credential file format"
|
|
}
|
|
}
|
|
}
|
|
}
|