diff --git a/Cargo.lock b/Cargo.lock index e7ad9aca..158a1309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borsh" version = "1.6.1" @@ -1729,6 +1738,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "oid-registry" version = "0.8.1" @@ -2151,6 +2175,7 @@ dependencies = [ "rama-http-core", "rama-net", "rama-net-apple-networkextension", + "rama-net-apple-xpc", "rama-proxy", "rama-socks5", "rama-tcp", @@ -2512,6 +2537,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "rama-net-apple-xpc" +version = "0.3.0-rc1" +source = "git+https://github.com/plabayo/rama?rev=9a0d105b73761fde9320dec76f5cfe071375bb7b#9a0d105b73761fde9320dec76f5cfe071375bb7b" +dependencies = [ + "ahash", + "bindgen 0.72.1", + "block2", + "parking_lot", + "rama-core", + "rama-net", + "rama-utils", + "serde", + "tokio", +] + [[package]] name = "rama-proxy" version = "0.3.0-rc1" @@ -2968,6 +3009,8 @@ dependencies = [ name = "safechain-lib-l4-proxy-macos" version = "1.0.0" dependencies = [ + "arc-swap", + "base64", "moka", "rama", "rustls-platform-verifier", diff --git a/Cargo.toml b/Cargo.toml index 1ce7b122..511d3ebb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ rust-version = "1.93" [workspace.dependencies] apple-native-keyring-store = "0.2" arc-swap = "1.9" +base64 = "0.22" bindgen = "0.71" clap = { version = "4.6" } humantime = "2.3" diff --git a/docs/proxy/l4_proxy/apple.md b/docs/proxy/l4_proxy/apple.md index 44f1a9a9..d8083bd5 100644 --- a/docs/proxy/l4_proxy/apple.md +++ b/docs/proxy/l4_proxy/apple.md @@ -245,6 +245,7 @@ Export recent logs to a file for sharing or later analysis: ```bash mkdir -p .aikido/logs +ts=$(date +%Y%m%dT%H%M%S) log show --last 30m --style compact --debug --info \ --predicate 'subsystem == "com.aikido.endpoint.proxy.l4" OR process == "com.aikido.endpoint.proxy.l4.dev.extension" diff --git a/justfile b/justfile index bff10b39..cb06fde9 100644 --- a/justfile +++ b/justfile @@ -215,10 +215,14 @@ macos-l4-log-stream: OR process == "com.aikido.endpoint.proxy.l4.dev.extension" \ OR process == "Aikido Network Extension"' + +macos-l4-cli *ARGS: + "{{xcode_l4_installed_app_exe}}" {{ARGS}} + macos-l4-start *ARGS: - "{{xcode_l4_installed_app_exe}}" start {{ARGS}} + just macos-l4-cli start {{ARGS}} @for i in $(seq 1 120); do \ - status="$("{{xcode_l4_installed_app_exe}}" status | sed -n 's/^status: //p')"; \ + status="$(just macos-l4-cli status | sed -n 's/^status: //p')"; \ echo "$i) status: $status"; \ case "$status" in \ connected) \ @@ -229,13 +233,13 @@ macos-l4-start *ARGS: sleep 0.5; \ done; \ echo "timed out waiting for macOS L4 proxy to become active" >&2; \ - "{{xcode_l4_installed_app_exe}}" status; \ + just macos-l4-cli status; \ exit 1 macos-l4-stop: - "{{xcode_l4_installed_app_exe}}" stop + just macos-l4-cli stop @for i in $(seq 1 120); do \ - status="$("{{xcode_l4_installed_app_exe}}" status | sed -n 's/^status: //p')"; \ + status="$(just macos-l4-cli status | sed -n 's/^status: //p')"; \ echo "$i) status: $status"; \ case "$status" in \ disconnected) \ diff --git a/packaging/macos/scripts/uninstall b/packaging/macos/scripts/uninstall index c680790c..9d41efd0 100755 --- a/packaging/macos/scripts/uninstall +++ b/packaging/macos/scripts/uninstall @@ -48,7 +48,12 @@ if [ -x "$L4_HOST" ]; then if [ "$UPGRADE_MODE" = true ]; then "$L4_HOST" stop 2>/dev/null && echo " ✓ L4 Proxy stopped" || echo " L4 Proxy not running" else - "$L4_HOST" stop --clean-secrets --remove-profile --deactivate-extension 2>/dev/null && echo " ✓ L4 Proxy stopped" || echo " L4 Proxy not running" + if "$L4_HOST" stop --remove-profile --deactivate-extension 2>/dev/null; then + echo " ✓ L4 Proxy stopped" + else + echo " L4 Proxy not running" + fi + "$L4_HOST" delete-ca-crt 2>/dev/null || true fi fi diff --git a/packaging/macos/xcode/l4-proxy/Extension/Extension.entitlements b/packaging/macos/xcode/l4-proxy/Extension/Extension.entitlements index b1b175b2..fd51535c 100644 --- a/packaging/macos/xcode/l4-proxy/Extension/Extension.entitlements +++ b/packaging/macos/xcode/l4-proxy/Extension/Extension.entitlements @@ -6,11 +6,9 @@ app-proxy-provider$(NE_ENTITLEMENT_SUFFIX) - com.apple.security.app-sandbox - - keychain-access-groups + com.apple.security.application-groups - $(AppIdentifierPrefix)$(AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID) + $(APP_GROUP_ID) com.apple.security.network.client diff --git a/packaging/macos/xcode/l4-proxy/Extension/Info.plist b/packaging/macos/xcode/l4-proxy/Extension/Info.plist index e6a5a82d..f3d8aa2c 100644 --- a/packaging/macos/xcode/l4-proxy/Extension/Info.plist +++ b/packaging/macos/xcode/l4-proxy/Extension/Info.plist @@ -25,6 +25,8 @@ com.apple.networkextension.app-proxy RamaAppleNetworkExtension.RamaTransparentProxyProvider + NEMachServiceName + $(APP_GROUP_ID).aikido-l4-xpc diff --git a/packaging/macos/xcode/l4-proxy/Host/Host.entitlements b/packaging/macos/xcode/l4-proxy/Host/Host.entitlements index c47eccb0..4311e560 100644 --- a/packaging/macos/xcode/l4-proxy/Host/Host.entitlements +++ b/packaging/macos/xcode/l4-proxy/Host/Host.entitlements @@ -8,8 +8,19 @@ com.apple.developer.system-extension.install - com.apple.security.app-sandbox - + com.apple.security.application-groups + + $(APP_GROUP_ID) + + keychain-access-groups $(AppIdentifierPrefix)$(AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID) diff --git a/packaging/macos/xcode/l4-proxy/Host/Info.plist b/packaging/macos/xcode/l4-proxy/Host/Info.plist index 307d7665..dfcb679e 100644 --- a/packaging/macos/xcode/l4-proxy/Host/Info.plist +++ b/packaging/macos/xcode/l4-proxy/Host/Info.plist @@ -22,5 +22,7 @@ $(AIKIDO_L4_EXTENSION_BUNDLE_ID) AikidoL4SharedAccessGroup $(AppIdentifierPrefix)$(AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID) + AikidoL4ProviderMachServiceName + $(APP_GROUP_ID).aikido-l4-xpc diff --git a/packaging/macos/xcode/l4-proxy/Host/RamaTproxyXpcRoutes.swift b/packaging/macos/xcode/l4-proxy/Host/RamaTproxyXpcRoutes.swift new file mode 100644 index 00000000..26eeadaa --- /dev/null +++ b/packaging/macos/xcode/l4-proxy/Host/RamaTproxyXpcRoutes.swift @@ -0,0 +1,30 @@ +import Foundation +import RamaAppleXpcClient + +/// Typed XPC routes exposed by the L4 sysext's router in +/// `proxy-lib-l4-macos/src/xpc_server.rs`. Selectors, field names, and +/// shapes must stay in sync with the Rust `serde` types on each route. + +enum AikidoL4GenerateCaCrt: RamaXpcRoute { + static let selector = "generateCaCrt:withReply:" + typealias Reply = AikidoL4CaCommandReply +} + +enum AikidoL4CommitCaCrt: RamaXpcRoute { + static let selector = "commitCaCrt:withReply:" + typealias Reply = AikidoL4CaCommandReply +} + +/// Shared reply for `generateCaCrt` / `commitCaCrt` (matches Rust +/// `CaCommandReply`). +/// +/// - `generateCaCrt`: `cert_der_b64` carries the freshly-minted (pending) +/// CA certificate so callers can install trust before committing. +/// - `commitCaCrt`: `cert_der_b64` carries the *previous* active CA, so +/// callers can drop its trust. Absent when there was nothing to displace +/// (first-ever commit). +struct AikidoL4CaCommandReply: Decodable { + let ok: Bool + let error: String? + let cert_der_b64: String? +} diff --git a/packaging/macos/xcode/l4-proxy/Host/main.swift b/packaging/macos/xcode/l4-proxy/Host/main.swift index 5c4832ea..ac669eea 100644 --- a/packaging/macos/xcode/l4-proxy/Host/main.swift +++ b/packaging/macos/xcode/l4-proxy/Host/main.swift @@ -1,18 +1,20 @@ -import CryptoKit import Darwin import Foundation import NetworkExtension import OSLog import ObjectiveC +import RamaAppleXpcClient import Security import SystemExtensions -import X509 private enum HostCommand { case start(StartOptions) case stop(StopOptions) case status - case cleanSecrets + case generateCaCrt + case commitCaCrt + case cleanupLegacyCaCrt + case deleteCaCrt case installExtension case allowVpn case isExtensionInstalled @@ -23,7 +25,6 @@ private enum HostCommand { private struct StopOptions { var removeProfile = false - var cleanSecrets = false var deactivateExtension = false } @@ -33,7 +34,6 @@ private struct StartOptions { var agentToken: String? var agentDeviceID: String? var resetProfile = false - var cleanSecrets = false var noFirewall = false } @@ -47,13 +47,25 @@ private struct AgentIdentityPayload: Encodable, Equatable { } } +/// Engine-config payload forwarded to the sysext through the opaque +/// `providerConfiguration` blob. Wire shape must stay in sync with +/// `ProxyConfig` in `proxy-lib-l4-macos/src/config.rs`. private struct ProxyEngineConfigPayload: Encodable, Equatable { let agentIdentity: AgentIdentityPayload? let reportingEndpoint: String? let aikidoURL: String? let hostBundleID: String + /// **DEPRECATED — graceful migration only.** PEM forwarded from + /// the legacy data-protection keychain when an older container + /// generated the CA before the sysext owned that responsibility. + /// The sysext uses it for the run only and never persists it. let caCertPEM: String? + /// **DEPRECATED — graceful migration only.** Counterpart to + /// [`Self.caCertPEM`]. let caKeyPEM: String? + let xpcServiceName: String? + let containerSigningIdentifier: String? + let containerTeamIdentifier: String? let noFirewall: Bool private enum CodingKeys: String, CodingKey { @@ -63,18 +75,14 @@ private struct ProxyEngineConfigPayload: Encodable, Equatable { case hostBundleID = "host_bundle_id" case caCertPEM = "ca_cert_pem" case caKeyPEM = "ca_key_pem" + case xpcServiceName = "xpc_service_name" + case containerSigningIdentifier = "container_signing_identifier" + case containerTeamIdentifier = "container_team_identifier" case noFirewall = "no_firewall" } - - var isEmpty: Bool { - agentIdentity == nil - && reportingEndpoint == nil - && aikidoURL == nil - && caCertPEM == nil - } } -private struct MITMCASecrets: Equatable { +private struct LegacyMITMCASecrets: Equatable { let certPEM: String let keyPEM: String } @@ -105,6 +113,19 @@ private final class TransparentProxyHostCLI { key: "AikidoL4ExtensionBundleIdentifier", fallback: "com.aikido.endpoint.proxy.l4.dev.extension" ) + /// `NEMachServiceName` exposed by the sysext's `Info.plist`. Forwarded + /// to the sysext so `XpcListenerConfig::new` and our client agree on + /// the same string. Intentionally read from the Host's `Info.plist` + /// (single source of truth) instead of being re-derived from the + /// bundle identifier — see the rama crate-level docs. + private lazy var xpcServiceName = infoString( + key: "AikidoL4ProviderMachServiceName", + fallback: "" + ) + private lazy var sharedAccessGroup = infoString( + key: "AikidoL4SharedAccessGroup", + fallback: "" + ) private lazy var logger = Logger( subsystem: "com.aikido.endpoint.proxy.l4", category: "host-main") func run(arguments: [String]) -> Int32 { @@ -121,8 +142,17 @@ private final class TransparentProxyHostCLI { case .status: try status() return EXIT_SUCCESS - case .cleanSecrets: - cleanSecrets() + case .generateCaCrt: + try generateCaCrt() + return EXIT_SUCCESS + case .commitCaCrt: + try commitCaCrt() + return EXIT_SUCCESS + case .cleanupLegacyCaCrt: + try cleanupLegacyCaCrt() + return EXIT_SUCCESS + case .deleteCaCrt: + try deleteCaCrt() return EXIT_SUCCESS case .installExtension: try installExtension() @@ -163,12 +193,19 @@ private final class TransparentProxyHostCLI { ) } - if options.cleanSecrets { - cleanSecrets() + let legacyCA = loadLegacyMITMCAOrNil() + if legacyCA != nil { + log( + "DEPRECATED: forwarding legacy MITM CA from data-protection keychain to sysext via opaque config. The sysext will use it for this run only and will NOT persist it. Rotate via `generate-ca-crt` + `commit-ca-crt` to retire the legacy CA." + ) } - let ca = try loadOrCreateMITMCA() - let engineConfigJSON = try Self.makeEngineConfigJSON(from: options, ca: ca) + let engineConfigJSON = try Self.makeEngineConfigJSON( + from: options, + legacyCA: legacyCA, + xpcServiceName: xpcServiceName.nilIfEmpty, + containerTeamIdentifier: containerTeamIdentifier() + ) let existingManagers = try loadManagers() if options.resetProfile { @@ -222,10 +259,6 @@ private final class TransparentProxyHostCLI { waitUntilDisconnected(manager: manager, attempts: 40) } - if options.cleanSecrets { - cleanSecrets() - } - if options.removeProfile { let managersToRemove = matchingManagers(from: managers) if !managersToRemove.isEmpty { @@ -256,6 +289,111 @@ private final class TransparentProxyHostCLI { print("status: \(statusString(manager.connection.status))") } + // MARK: - CA commands + + private func generateCaCrt() throws { + let serviceName = try requireXpcServiceName() + log("generate-ca-crt: invoking XPC route on \(serviceName)") + let reply = try runXpc { client in + try await client.call(AikidoL4GenerateCaCrt.self) + } + if !reply.ok { + throw CLIError.runtime( + "generate-ca-crt failed in sysext: \(reply.error ?? "unknown error)")") + } + guard let der = reply.cert_der_b64 else { + throw CLIError.runtime( + "generate-ca-crt sysext reply missing `cert_der_b64`; refusing to claim success" + ) + } + // Single line: `cert_der_b64: `. Stable for callers parsing + // stdout (e.g. the Go daemon). + print("cert_der_b64: \(der)") + } + + private func commitCaCrt() throws { + let serviceName = try requireXpcServiceName() + log("commit-ca-crt: invoking XPC route on \(serviceName)") + let reply = try runXpc { client in + try await client.call(AikidoL4CommitCaCrt.self) + } + if !reply.ok { + throw CLIError.runtime( + "commit-ca-crt failed in sysext: \(reply.error ?? "unknown error)")") + } + // sysext successfully swapped the active CA. Now retire the legacy + // data-protection-keychain entries (idempotent; no-op when absent). + // This is the only point at which legacy state is removed: until + // commit lands, callers may need it for rollback. + let legacyOutcome = deleteLegacyDataProtectionEntries() + + if let der = reply.cert_der_b64 { + print("previous_cert_der_b64: \(der)") + } else { + print("previous_cert_der_b64:") + } + + // The new CA is already active in the sysext. If we could not retire + // the legacy plaintext key material, surface that to the caller via a + // non-zero exit so it doesn't get logged-and-forgotten — leaving the + // old private key sitting in the data-protection keychain is a real + // (if narrow) audit finding. The caller still has the previous DER + // it needs from stdout above, and can re-run commit-ca-crt to retry + // the cleanup; both keychain ops are idempotent. + if case .partial(let messages) = legacyOutcome { + // The sysext swap succeeded and the new CA is live, but the + // legacy plaintext key material is still sitting in the + // data-protection keychain. The sysext now prefers SE-backed CAs + // over legacy, so this is not a runtime regression — but the + // caller MUST know about it (audit / hygiene). Run + // `cleanup-legacy-ca-crt` to retry; both keychain ops are + // idempotent. + throw CLIError.runtime( + "commit-ca-crt: rotation committed in sysext, but legacy data-protection keychain cleanup failed (\(messages.joined(separator: "; "))). Run `cleanup-legacy-ca-crt` to retry." + ) + } + } + + private func cleanupLegacyCaCrt() throws { + let outcome = deleteLegacyDataProtectionEntries() + switch outcome { + case .ok: + print("legacy-ca-crt: cleaned") + case .partial(let messages): + // Exit non-zero so automation can distinguish "cleaned" from + // "still left behind". Print the marker line first anyway so a + // caller scanning stdout can see we tried. + print("legacy-ca-crt: cleanup-incomplete") + throw CLIError.runtime( + "cleanup-legacy-ca-crt: \(messages.joined(separator: "; "))" + ) + } + } + + private func deleteCaCrt() throws { + // No XPC: we are nuking every keychain artefact that may carry + // MITM CA material on this machine. The sysext's in-memory copy + // of the CA survives until the next sysext restart — that is + // expected: callers pair `delete-ca-crt` with a tunnel + // stop/restart when a hard reset is intended. + let legacyOutcome = deleteLegacyDataProtectionEntries() + let systemOutcome = deleteSystemKeychainCAEntries() + + var warnings: [String] = [] + if case .partial(let m) = legacyOutcome { warnings.append(contentsOf: m) } + if case .partial(let m) = systemOutcome { warnings.append(contentsOf: m) } + + if warnings.isEmpty { + print("ca-crt: deleted") + return + } + // Exit non-zero so automation can distinguish full delete from + // partial. Marker line is still emitted up-front for stdout-scanning + // callers. + print("ca-crt: delete-incomplete") + throw CLIError.runtime("delete-ca-crt: \(warnings.joined(separator: "; "))") + } + private func installExtension() throws { let outcome = try ensureSystemExtensionActivated() switch outcome { @@ -319,6 +457,8 @@ private final class TransparentProxyHostCLI { print("vpn-allowed: \(allowed)") } + // MARK: - NE machinery + private func loadManagers() throws -> [NETransparentProxyManager] { try waitForResult("load transparent proxy managers") { completion in NETransparentProxyManager.loadAllFromPreferences { managers, error in @@ -635,34 +775,240 @@ private final class TransparentProxyHostCLI { } } - private static let secretAccount = "safechain-lib-l4-proxy-macos" - private static let secretServiceKeyPEM = "tls-root-selfsigned-ca-key" - private static let secretServiceCertPEM = "tls-root-selfsigned-ca-crt" - private static let secretServiceKeys = [ - secretServiceKeyPEM, - secretServiceCertPEM, + // MARK: - XPC plumbing + + private func requireXpcServiceName() throws -> String { + let name = xpcServiceName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { + throw CLIError.runtime( + "AikidoL4ProviderMachServiceName missing from Info.plist; the host CLI cannot reach the sysext over XPC. Rebuild the Host bundle with the patched Info.plist." + ) + } + return name + } + + private func containerTeamIdentifier() -> String? { + let group = sharedAccessGroup.trimmingCharacters(in: .whitespacesAndNewlines) + guard !group.isEmpty else { + return nil + } + guard let prefix = group.split(separator: ".", maxSplits: 1).first else { + return nil + } + let teamID = String(prefix) + return teamID.isEmpty ? nil : teamID + } + + private func runXpc(_ body: @escaping (RamaXpcClient) async throws -> T) throws -> T { + let serviceName = try requireXpcServiceName() + let client = RamaXpcClient(serviceName: serviceName) + + var result: Result? + let semaphore = DispatchSemaphore(value: 0) + + Task.detached { + do { + let value = try await body(client) + result = .success(value) + } catch { + result = .failure(error) + } + semaphore.signal() + } + + // 30s is plenty for one-shot generate / commit calls. Persist of a + // freshly-minted CA does an SE encrypt + 3 keychain writes — sub-second + // in practice. Tunable here if it ever changes. + let deadline = DispatchTime.now() + .seconds(30) + if semaphore.wait(timeout: deadline) == .timedOut { + throw CLIError.runtime( + "timed out waiting for XPC reply from sysext (service: \(serviceName))" + ) + } + + switch result { + case .success(let value): + return value + case .failure(let err): + throw CLIError.runtime( + "XPC call to sysext failed (service: \(serviceName)): \(err.localizedDescription)" + ) + case .none: + throw CLIError.runtime( + "XPC call to sysext returned no result (service: \(serviceName))" + ) + } + } + + // MARK: - Legacy data-protection keychain (graceful migration) + + /// **DEPRECATED — graceful migration only.** Older container builds + /// stored the CA inside the user's data-protection keychain under + /// these constants. We keep the ability to *load* such material so it + /// can be passed to the sysext while a customer migrates; we never + /// store anything there ourselves anymore. Once the graceful period + /// ends the entire branch (constants + helpers) can be removed. + private static let legacySecretAccount = "safechain-lib-l4-proxy-macos" + private static let legacySecretServiceKeyPEM = "tls-root-selfsigned-ca-key" + private static let legacySecretServiceCertPEM = "tls-root-selfsigned-ca-crt" + private static let legacySecretServices = [ + legacySecretServiceKeyPEM, + legacySecretServiceCertPEM, ] - private func cleanSecrets() { - for key in Self.secretServiceKeys { + private func loadLegacyMITMCAOrNil() -> LegacyMITMCASecrets? { + let key = try? loadLegacySecret(service: Self.legacySecretServiceKeyPEM) + let cert = try? loadLegacySecret(service: Self.legacySecretServiceCertPEM) + guard let key = key ?? nil, let cert = cert ?? nil else { + return nil + } + return LegacyMITMCASecrets(certPEM: cert, keyPEM: key) + } + + private func loadLegacySecret(service: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: Self.legacySecretAccount, + kSecUseDataProtectionKeychain as String: true, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + switch status { + case errSecSuccess: + guard let data = item as? Data else { + throw CLIError.runtime("legacy keychain item for \(service) did not return Data") + } + guard let value = String(data: data, encoding: .utf8) else { + throw CLIError.runtime("legacy keychain item for \(service) was not valid UTF-8") + } + return value + case errSecItemNotFound: + return nil + default: + throw CLIError.runtime( + "failed to load legacy keychain secret \(service): OSStatus \(status)") + } + } + + @discardableResult + private func deleteLegacyDataProtectionEntries() -> DeleteOutcome { + var warnings: [String] = [] + for service in Self.legacySecretServices { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: key, - kSecAttrAccount as String: Self.secretAccount, + kSecAttrService as String: service, + kSecAttrAccount as String: Self.legacySecretAccount, kSecUseDataProtectionKeychain as String: true, ] - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess { - log("deleted keychain secret: \(key)") - } else if status != errSecItemNotFound { - log("failed to delete keychain secret \(key): OSStatus \(status)") + switch status { + case errSecSuccess: + log("deleted legacy data-protection keychain entry: \(service)") + case errSecItemNotFound: + continue + default: + let msg = + "failed to delete legacy data-protection keychain entry \(service): OSStatus \(status)" + log(msg) + warnings.append(msg) } } + return warnings.isEmpty ? .ok : .partial(warnings) + } + + // MARK: - System Keychain (SE-encrypted CA storage) + + /// Service / account constants must match the sysext side + /// (`proxy-lib-l4-macos/src/tls.rs`). Keep in sync. + private static let systemCAAccount = "com.aikido.endpoint.proxy.l4" + private static let systemCAServiceCert = "aikido-l4-mitm-ca-crt" + private static let systemCAServiceKey = "aikido-l4-mitm-ca-key" + private static let systemCAServiceSEKey = "aikido-l4-mitm-ca-se-key" + private static let systemCAAllServices = [ + systemCAServiceSEKey, + systemCAServiceCert, + systemCAServiceKey, + ] - print("secrets: cleaned") + private enum DeleteOutcome { + case ok + case partial([String]) } + private func deleteSystemKeychainCAEntries() -> DeleteOutcome { + // We must hit the file-based System Keychain + // (`/Library/Keychains/System.keychain`), which is what the sysext + // writes to via `SecKeychainAddGenericPassword`. The modern + // `SecItem*` APIs default to the user's keychains and ignore that + // file even with `kSecUseDataProtectionKeychain: false`, so we have + // to drive the same legacy `SecKeychain*` family rama uses on the + // sysext side. Writing/deleting there requires root privileges (or + // an admin auth prompt); the Aikido CLI runs as root in the + // daemon-driven flow. + var keychain: SecKeychain? + let openStatus = "/Library/Keychains/System.keychain".withCString { path in + SecKeychainOpen(path, &keychain) + } + guard openStatus == errSecSuccess, let keychain else { + return .partial([ + "failed to open /Library/Keychains/System.keychain: OSStatus \(openStatus)" + ]) + } + + var warnings: [String] = [] + for service in Self.systemCAAllServices { + let serviceBytes = Array(service.utf8) + let accountBytes = Array(Self.systemCAAccount.utf8) + var item: SecKeychainItem? + + let findStatus = serviceBytes.withUnsafeBufferPointer { svc in + accountBytes.withUnsafeBufferPointer { acc in + SecKeychainFindGenericPassword( + keychain, + UInt32(svc.count), + svc.baseAddress, + UInt32(acc.count), + acc.baseAddress, + nil, + nil, + &item + ) + } + } + + switch findStatus { + case errSecSuccess: + guard let item else { + continue + } + let deleteStatus = SecKeychainItemDelete(item) + if deleteStatus == errSecSuccess { + log("deleted System Keychain entry: \(service)") + } else { + let msg = + "failed to delete System Keychain entry \(service): OSStatus \(deleteStatus)" + log(msg) + warnings.append(msg) + } + case errSecItemNotFound: + continue + default: + let msg = + "failed to look up System Keychain entry \(service): OSStatus \(findStatus)" + log(msg) + warnings.append(msg) + } + } + return warnings.isEmpty ? .ok : .partial(warnings) + } + + // MARK: - Logging + private func log(_ message: String) { logger.info("\(message, privacy: .public)") } @@ -703,47 +1049,46 @@ private final class TransparentProxyHostCLI { let stopArguments = Array(arguments.dropFirst()) return .stop(try parseStopOptions(arguments: stopArguments)) case "status": - guard arguments.count == 1 else { - throw CLIError.usage("`status` does not accept additional arguments") - } + try assertNoExtraArgs(arguments, command: "status") return .status - case "clean-secrets": - guard arguments.count == 1 else { - throw CLIError.usage("`clean-secrets` does not accept additional arguments") - } - return .cleanSecrets + case "generate-ca-crt": + try assertNoExtraArgs(arguments, command: "generate-ca-crt") + return .generateCaCrt + case "commit-ca-crt": + try assertNoExtraArgs(arguments, command: "commit-ca-crt") + return .commitCaCrt + case "cleanup-legacy-ca-crt": + try assertNoExtraArgs(arguments, command: "cleanup-legacy-ca-crt") + return .cleanupLegacyCaCrt + case "delete-ca-crt": + try assertNoExtraArgs(arguments, command: "delete-ca-crt") + return .deleteCaCrt case "install-extension": - guard arguments.count == 1 else { - throw CLIError.usage("`install-extension` does not accept additional arguments") - } + try assertNoExtraArgs(arguments, command: "install-extension") return .installExtension case "allow-vpn": - guard arguments.count == 1 else { - throw CLIError.usage("`allow-vpn` does not accept additional arguments") - } + try assertNoExtraArgs(arguments, command: "allow-vpn") return .allowVpn case "is-extension-installed": - guard arguments.count == 1 else { - throw CLIError.usage( - "`is-extension-installed` does not accept additional arguments") - } + try assertNoExtraArgs(arguments, command: "is-extension-installed") return .isExtensionInstalled case "is-extension-activated": - guard arguments.count == 1 else { - throw CLIError.usage( - "`is-extension-activated` does not accept additional arguments") - } + try assertNoExtraArgs(arguments, command: "is-extension-activated") return .isExtensionActivated case "is-vpn-allowed": - guard arguments.count == 1 else { - throw CLIError.usage("`is-vpn-allowed` does not accept additional arguments") - } + try assertNoExtraArgs(arguments, command: "is-vpn-allowed") return .isVpnAllowed default: throw CLIError.usage("unknown command: \(first)") } } + private static func assertNoExtraArgs(_ arguments: [String], command: String) throws { + guard arguments.count == 1 else { + throw CLIError.usage("`\(command)` does not accept additional arguments") + } + } + private static func parseStartOptions(arguments: [String]) throws -> StartOptions { var options = StartOptions() var index = 0 @@ -767,8 +1112,6 @@ private final class TransparentProxyHostCLI { flag: argument, arguments: arguments, index: &index) case "--reset-profile": options.resetProfile = true - case "--clean-secrets": - options.cleanSecrets = true case "--no-firewall": options.noFirewall = true default: @@ -795,8 +1138,6 @@ private final class TransparentProxyHostCLI { switch argument { case "--remove-profile": options.removeProfile = true - case "--clean-secrets": - options.cleanSecrets = true case "--deactivate-extension": options.deactivateExtension = true default: @@ -841,7 +1182,9 @@ private final class TransparentProxyHostCLI { private static func makeEngineConfigJSON( from options: StartOptions, - ca: MITMCASecrets + legacyCA: LegacyMITMCASecrets?, + xpcServiceName: String?, + containerTeamIdentifier: String? ) throws -> String? { let agentIdentity: AgentIdentityPayload? if let token = options.agentToken, let deviceID = options.agentDeviceID { @@ -850,13 +1193,18 @@ private final class TransparentProxyHostCLI { agentIdentity = nil } + let containerSigningIdentifier = Bundle.main.bundleIdentifier ?? "com.aikido.endpoint.proxy.l4.dev" + let payload = ProxyEngineConfigPayload( agentIdentity: agentIdentity, reportingEndpoint: options.reportingEndpoint, aikidoURL: options.aikidoURL, - hostBundleID: Bundle.main.bundleIdentifier ?? "com.aikido.endpoint.proxy.l4.dev", - caCertPEM: ca.certPEM, - caKeyPEM: ca.keyPEM, + hostBundleID: containerSigningIdentifier, + caCertPEM: legacyCA?.certPEM, + caKeyPEM: legacyCA?.keyPEM, + xpcServiceName: xpcServiceName, + containerSigningIdentifier: containerSigningIdentifier, + containerTeamIdentifier: containerTeamIdentifier, noFirewall: options.noFirewall ) @@ -869,167 +1217,6 @@ private final class TransparentProxyHostCLI { return json } - private func loadOrCreateMITMCA() throws -> MITMCASecrets { - let existingKey = try loadSecret(service: Self.secretServiceKeyPEM) - let existingCert = try loadSecret(service: Self.secretServiceCertPEM) - - if let keyPEM = existingKey, let certPEM = existingCert { - log("loaded MITM CA PEM from keychain") - return MITMCASecrets(certPEM: certPEM, keyPEM: keyPEM) - } - - if existingKey != nil || existingCert != nil { - log("MITM CA keychain state incomplete; deleting partial CA material and regenerating") - cleanSecrets() - } - - let generated = try generateSelfSignedCAPEM() - try storeSecret(service: Self.secretServiceKeyPEM, value: generated.keyPEM) - try storeSecret(service: Self.secretServiceCertPEM, value: generated.certPEM) - log("generated and stored new MITM CA PEM in keychain") - return generated - } - - private func loadSecret(service: String) throws -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: Self.secretAccount, - kSecUseDataProtectionKeychain as String: true, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - - switch status { - case errSecSuccess: - guard let data = item as? Data else { - throw CLIError.runtime("keychain item for \(service) did not return Data") - } - guard let value = String(data: data, encoding: .utf8) else { - throw CLIError.runtime("keychain item for \(service) was not valid UTF-8") - } - return value - case errSecItemNotFound: - return nil - default: - throw CLIError.runtime("failed to load keychain secret \(service): OSStatus \(status)") - } - } - - private func storeSecret(service: String, value: String) throws { - guard let data = value.data(using: .utf8) else { - throw CLIError.runtime("failed to encode keychain secret \(service) as UTF-8") - } - - let baseQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: Self.secretAccount, - kSecUseDataProtectionKeychain as String: true, - ] - - let updateAttrs: [String: Any] = [ - kSecValueData as String: data - ] - - let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary) - if updateStatus == errSecSuccess { - return - } - - if updateStatus != errSecItemNotFound { - throw CLIError.runtime( - "failed to update keychain secret \(service): OSStatus \(updateStatus)") - } - - var addQuery = baseQuery - addQuery[kSecValueData as String] = data - if let accessControl = createAccessControl() { - addQuery[kSecAttrAccessControl as String] = accessControl - } - - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - if addStatus != errSecSuccess { - throw CLIError.runtime( - "failed to add keychain secret \(service): OSStatus \(addStatus)") - } - } - - private func generateSelfSignedCAPEM() throws -> MITMCASecrets { - let signingKey = P256.Signing.PrivateKey() - let now = Date() - let calendar = Calendar(identifier: .gregorian) - guard let notValidAfter = calendar.date(byAdding: .day, value: 3650, to: now) else { - throw CLIError.runtime("failed to compute CA certificate expiry date") - } - - let subject = try DistinguishedName { - CommonName("Aikido Endpoint L4 Proxy Root CA") - OrganizationName("Aikido") - OrganizationalUnitName("Endpoint") - CountryName("BE") - } - - let certificate = try Certificate( - version: .v3, - serialNumber: .init(), - publicKey: .init(signingKey.publicKey), - notValidBefore: now, - notValidAfter: notValidAfter, - issuer: subject, - subject: subject, - signatureAlgorithm: .ecdsaWithSHA256, - extensions: try Certificate.Extensions { - Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0)) - Critical( - KeyUsage( - digitalSignature: true, - nonRepudiation: false, - keyEncipherment: false, - dataEncipherment: false, - keyAgreement: false, - keyCertSign: true, - cRLSign: true, - encipherOnly: false, - decipherOnly: false - ) - ) - }, - issuerPrivateKey: .init(signingKey) - ) - - let certPEM = try certificate.serializeAsPEM().pemString - let keyPEM = try Certificate.PrivateKey(signingKey).serializeAsPEM().pemString - - guard certPEM.contains("BEGIN CERTIFICATE") else { - throw CLIError.runtime("generated CA certificate PEM had unexpected format") - } - guard keyPEM.contains("BEGIN PRIVATE KEY") || keyPEM.contains("BEGIN EC PRIVATE KEY") else { - throw CLIError.runtime("generated CA private key PEM had unexpected format") - } - - return MITMCASecrets(certPEM: certPEM, keyPEM: keyPEM) - } - - private func createAccessControl() -> SecAccessControl? { - var error: Unmanaged? - let access = SecAccessControlCreateWithFlags( - nil, - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - [], - &error - ) - - if let err = error { - logError("failed to create access control for keychain secret", err.takeRetainedValue()) - } - - return access - } - private func runProcessCaptureStdout( launchPath: String, arguments: [String], @@ -1093,7 +1280,10 @@ private final class TransparentProxyHostCLI { "Aikido Network Extension" start [options] "Aikido Network Extension" stop [options] "Aikido Network Extension" status - "Aikido Network Extension" clean-secrets + "Aikido Network Extension" generate-ca-crt + "Aikido Network Extension" commit-ca-crt + "Aikido Network Extension" cleanup-legacy-ca-crt + "Aikido Network Extension" delete-ca-crt "Aikido Network Extension" install-extension "Aikido Network Extension" allow-vpn "Aikido Network Extension" is-extension-installed @@ -1103,8 +1293,27 @@ private final class TransparentProxyHostCLI { Commands: start Install or update the transparent proxy profile and request that it starts. stop Request that the transparent proxy tunnel stops. - status Show the current Network Extension status and saved engine config. - clean-secrets Delete proxy CA secrets from the keychain. + status Show the current Network Extension status. + generate-ca-crt Ask the sysext to mint a fresh MITM CA in memory and park it + as the pending one. The active TLS interception keeps using + the previous CA, but the hijack endpoint serves the pending + PEM so callers can install trust before commit. Prints the + new cert DER (base64) on stdout as `cert_der_b64: `. + commit-ca-crt Persist the pending CA in the SE-encrypted system keychain + and atomically swap it in as the active CA. Fails when no + pending CA is parked. On success, also wipes any legacy CA + entries in the data-protection keychain. Prints the previous + active cert DER (base64) on stdout as + `previous_cert_der_b64: ` (empty when nothing was displaced). + Exits non-zero if the rotation succeeded but the legacy + cleanup step failed; run `cleanup-legacy-ca-crt` to retry. + cleanup-legacy-ca-crt Idempotent. Wipes the legacy data-protection keychain entries + left over from pre-sysext-owned-CA installs. Safe to run any + time; does not touch the SE-encrypted active CA. + delete-ca-crt Wipe every MITM CA artefact from the keychains: SE-wrapped + key blob, encrypted cert, encrypted key (system keychain), + and the legacy data-protection entries. Idempotent. Note: the + sysext keeps its in-memory CA copy until restart. install-extension Install the system extension (triggers Network Extension approval). allow-vpn Save the VPN profile (triggers Allow VPN Configuration approval). is-extension-installed Check if the system extension appears in the extensions list (no prompts). @@ -1113,7 +1322,6 @@ private final class TransparentProxyHostCLI { Stop options: --remove-profile Remove the saved Network Extension profile after stopping. - --clean-secrets Delete proxy CA secrets from the keychain. --deactivate-extension Deactivate the system extension (for uninstall). Start options: @@ -1121,15 +1329,16 @@ private final class TransparentProxyHostCLI { --aikido-url URL Override the Aikido app base URL used by the extension. --agent-token TOKEN Agent token to forward to the extension config. --agent-device-id ID Agent device identifier to forward to the extension config. - --clean-secrets Delete proxy CA secrets before starting to rotate the MITM CA. --reset-profile Remove the saved Network Extension profile before starting. + --no-firewall Don't setup the firewall. --help Show this help text. - --no-firewall Don't setup the firewall Notes: - The transparent proxy extension is managed by macOS after `start`; this host process does not need to stay alive for the proxy to keep running. - Provide both `--agent-token` and `--agent-device-id` together or omit both. + - `generate-ca-crt` / `commit-ca-crt` reach the sysext over XPC. The Mach service name + comes from the Host bundle's `AikidoL4ProviderMachServiceName` Info.plist key. """ } @@ -1145,6 +1354,12 @@ private enum AssociatedKeys { static var systemExtensionDelegate: UInt8 = 0 } +extension String { + fileprivate var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + private final class SystemExtensionRequestDelegate: NSObject, OSSystemExtensionRequestDelegate { private let extensionBundleId: String private let onFinish: (SystemExtensionActivationOutcome) -> Void diff --git a/packaging/macos/xcode/l4-proxy/Project.dev.yml b/packaging/macos/xcode/l4-proxy/Project.dev.yml index fdb8c57c..243d1d37 100644 --- a/packaging/macos/xcode/l4-proxy/Project.dev.yml +++ b/packaging/macos/xcode/l4-proxy/Project.dev.yml @@ -8,9 +8,6 @@ packages: RamaAppleNetworkExtension: url: https://github.com/plabayo/rama.git revision: 9a0d105b73761fde9320dec76f5cfe071375bb7b - X509: - url: https://github.com/apple/swift-certificates.git - from: 1.0.0 targets: AikidoNetworkExtensionHost: @@ -22,8 +19,8 @@ targets: - target: AikidoNetworkExtensionSysext embed: true codeSign: true - - package: X509 - product: X509 + - package: RamaAppleNetworkExtension + product: RamaAppleXpcClient settings: base: PRODUCT_NAME: "Aikido Network Extension" @@ -41,6 +38,7 @@ targets: NE_ENTITLEMENT_SUFFIX: "" AIKIDO_L4_EXTENSION_BUNDLE_ID: com.aikido.endpoint.proxy.l4.dev.extension AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID: com.aikido.endpoint.proxy.l4.dev.extension + APP_GROUP_ID: "$(AppIdentifierPrefix)com.aikido.endpoint.proxy.l4.dev.extension" ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon AikidoNetworkExtensionSysext: @@ -80,6 +78,7 @@ targets: CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES NE_ENTITLEMENT_SUFFIX: "" AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID: com.aikido.endpoint.proxy.l4.dev.extension + APP_GROUP_ID: "$(AppIdentifierPrefix)com.aikido.endpoint.proxy.l4.dev.extension" ENABLE_DEBUG_DYLIB: NO OTHER_LDFLAGS: - "$(SRCROOT)/../../../../target/universal/libsafechain_lib_l4_proxy_macos.a" diff --git a/packaging/macos/xcode/l4-proxy/Project.dist.yml b/packaging/macos/xcode/l4-proxy/Project.dist.yml index eb495c09..6ab20864 100644 --- a/packaging/macos/xcode/l4-proxy/Project.dist.yml +++ b/packaging/macos/xcode/l4-proxy/Project.dist.yml @@ -8,9 +8,6 @@ packages: RamaAppleNetworkExtension: url: https://github.com/plabayo/rama.git revision: 9a0d105b73761fde9320dec76f5cfe071375bb7b - X509: - url: https://github.com/apple/swift-certificates.git - from: 1.0.0 targets: AikidoNetworkExtensionHost: @@ -22,8 +19,8 @@ targets: - target: AikidoNetworkExtensionSysext embed: true codeSign: true - - package: X509 - product: X509 + - package: RamaAppleNetworkExtension + product: RamaAppleXpcClient settings: base: PRODUCT_NAME: "Aikido Network Extension" @@ -42,6 +39,7 @@ targets: NE_ENTITLEMENT_SUFFIX: "-systemextension" AIKIDO_L4_EXTENSION_BUNDLE_ID: com.aikido.endpoint.proxy.l4.dist.extension AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID: com.aikido.endpoint.proxy.l4.dist.extension + APP_GROUP_ID: "$(AppIdentifierPrefix)com.aikido.endpoint.proxy.l4.dist.extension" CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO GENERATE_PROVISIONING_PROFILE: NO ENABLE_HARDENED_RUNTIME: YES @@ -77,6 +75,7 @@ targets: CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES NE_ENTITLEMENT_SUFFIX: "-systemextension" AIKIDO_L4_SHARED_ACCESS_GROUP_BUNDLE_ID: com.aikido.endpoint.proxy.l4.dist.extension + APP_GROUP_ID: "$(AppIdentifierPrefix)com.aikido.endpoint.proxy.l4.dist.extension" CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO GENERATE_PROVISIONING_PROFILE: NO ENABLE_HARDENED_RUNTIME: YES diff --git a/proxy-bin-l4/src/tcp/mod.rs b/proxy-bin-l4/src/tcp/mod.rs index 9265c7f9..2e1f2045 100644 --- a/proxy-bin-l4/src/tcp/mod.rs +++ b/proxy-bin-l4/src/tcp/mod.rs @@ -146,11 +146,12 @@ async fn try_new_tcp_service( let root_ca_key_pair = tls::load_or_create_root_ca_key_pair(&secret_storage, &data_storage) .context("prepare proxy traffic CA crt/key pair")?; - let ca_crt_pem_bytes: &[u8] = root_ca_key_pair - .certificate() - .to_pem() - .context("convert cert to pem")? - .leak(); + let ca_crt_pem_bytes = rama::bytes::Bytes::from( + root_ca_key_pair + .certificate() + .to_pem() + .context("convert cert to pem")?, + ); let (ca_crt, ca_key) = root_ca_key_pair.into_pair(); @@ -181,7 +182,7 @@ async fn try_new_tcp_service( tls_mitm_relay_policy, tls_mitm_relay, firewall, - ca_crt_pem_bytes, + ca_crt_pem_bytes.clone(), false, ); @@ -201,7 +202,7 @@ fn new_tcp_service_inner( tls_mitm_relay_policy: TlsMitmRelayPolicyLayer, tls_mitm_relay: TlsMitmRelay, firewall: Firewall, - ca_crt_pem_bytes: &'static [u8], + ca_crt_pem_bytes: rama::bytes::Bytes, within_connect_tunnel: bool, ) -> impl Service, Output = (), Error = Infallible> + Clone where @@ -248,7 +249,7 @@ fn http_relay_middleware( tls_mitm_relay_policy: TlsMitmRelayPolicyLayer, tls_mitm_relay: TlsMitmRelay, firewall: Firewall, - ca_crt_pem_bytes: &'static [u8], + ca_crt_pem_bytes: rama::bytes::Bytes, within_connect_tunnel: bool, ) -> impl Layer + Clone> + Send @@ -270,7 +271,7 @@ where tls_mitm_relay_policy, tls_mitm_relay, firewall.clone(), - ca_crt_pem_bytes, + ca_crt_pem_bytes.clone(), true, ) .boxed() diff --git a/proxy-bin-l7/src/server/proxy/mod.rs b/proxy-bin-l7/src/server/proxy/mod.rs index 1d079438..05366618 100644 --- a/proxy-bin-l7/src/server/proxy/mod.rs +++ b/proxy-bin-l7/src/server/proxy/mod.rs @@ -62,11 +62,12 @@ pub async fn run_proxy_server( let https_client = self::server::http_relay_middleware( exec.clone(), firewall.clone(), - root_ca_key_pair - .certificate() - .to_pem() - .context("root ca cert as pem")? - .leak(), + rama::bytes::Bytes::from( + root_ca_key_pair + .certificate() + .to_pem() + .context("root ca cert as pem")?, + ), #[cfg(feature = "har")] har_export_layer.clone(), ) diff --git a/proxy-bin-l7/src/server/proxy/server.rs b/proxy-bin-l7/src/server/proxy/server.rs index b47df3ee..9991d86a 100644 --- a/proxy-bin-l7/src/server/proxy/server.rs +++ b/proxy-bin-l7/src/server/proxy/server.rs @@ -60,11 +60,12 @@ pub(super) fn new_app_mitm_server( ) -> Result + Clone, BoxError> { let exec = Executor::graceful(guard); - let ca_crt_pem_bytes: &[u8] = root_ca - .certificate() - .to_pem() - .context("root ca cert as pem")? - .leak(); + let ca_crt_pem_bytes = rama::bytes::Bytes::from( + root_ca + .certificate() + .to_pem() + .context("root ca cert as pem")?, + ); let (ca_crt, ca_key) = root_ca.into_pair(); @@ -105,7 +106,7 @@ pub(super) fn new_app_mitm_server( pub fn http_relay_middleware( exec: Executor, firewall: Firewall, - ca_crt_pem_bytes: &'static [u8], + ca_crt_pem_bytes: rama::bytes::Bytes, #[cfg(feature = "har")] har_export_layer: HARExportLayer, ) -> impl Layer + Clone> + Send diff --git a/proxy-lib-l4-macos/Cargo.toml b/proxy-lib-l4-macos/Cargo.toml index 646e21fc..ecf573d1 100644 --- a/proxy-lib-l4-macos/Cargo.toml +++ b/proxy-lib-l4-macos/Cargo.toml @@ -13,6 +13,8 @@ crate-type = ["staticlib"] [features] [target.'cfg(target_os = "macos")'.dependencies] +arc-swap = { workspace = true } +base64 = { workspace = true } jemallocator = { workspace = true } moka = { workspace = true } rustls-platform-verifier = { workspace = true } @@ -26,6 +28,7 @@ tracing-oslog = { workspace = true } workspace = true features = [ "net-apple-networkextension", + "net-apple-xpc", "tcp", "udp", "dns", diff --git a/proxy-lib-l4-macos/src/config.rs b/proxy-lib-l4-macos/src/config.rs index 253cc6f6..108776da 100644 --- a/proxy-lib-l4-macos/src/config.rs +++ b/proxy-lib-l4-macos/src/config.rs @@ -3,6 +3,7 @@ use std::borrow::Cow; use rama::{ error::{BoxError, ErrorContext as _}, http::Uri, + utils::str::arcstr::ArcStr, }; use safechain_proxy_lib::utils::token::AgentIdentity; use serde::{Deserialize, Deserializer}; @@ -56,12 +57,32 @@ pub struct ProxyConfig { #[serde(deserialize_with = "deserialize_uri")] pub aikido_url: Uri, - /// PEM-encoded root CA certificate supplied by the host. + /// **DEPRECATED — graceful migration only.** PEM-encoded root CA + /// certificate forwarded by an older container build that still + /// generates the CA itself. The sysext uses it for the current run + /// only; it is **never** persisted in the SE-encrypted system + /// keychain. Caller is expected to retire it via the XPC + /// `generate-ca-crt` + `commit-ca-crt` flow. pub ca_cert_pem: Option, - /// PEM-encoded root CA private key supplied by the host. + /// **DEPRECATED — graceful migration only.** PEM-encoded root CA + /// private key counterpart to [`ProxyConfig::ca_cert_pem`]. pub ca_key_pem: Option, + /// `NEMachServiceName` exposed by the sysext's `Info.plist`. When + /// present, the sysext binds an XPC listener under this name so the + /// container app can drive `generate-ca-crt` / `commit-ca-crt`. + pub xpc_service_name: Option, + + /// Bundle identifier of the container app. Used to pin the XPC + /// listener to this exact code-signed application. + pub container_signing_identifier: Option, + + /// Apple Developer team identifier of the container app. Used with + /// [`ProxyConfig::container_signing_identifier`] to build the exact + /// XPC code-signing requirement. + pub container_team_identifier: Option, + /// Disables the firewall, this is used for the first time the proxy starts and certs are not trusted yet /// The daemon will first start the proxy in this mode to obtain the cert and make it trusted. pub no_firewall: bool, @@ -77,6 +98,9 @@ impl Default for ProxyConfig { aikido_url: Uri::from_static("https://app.aikido.dev"), ca_cert_pem: None, ca_key_pem: None, + xpc_service_name: None, + container_signing_identifier: None, + container_team_identifier: None, no_firewall: false, } } diff --git a/proxy-lib-l4-macos/src/handler.rs b/proxy-lib-l4-macos/src/handler.rs index 2999196e..02cd2db2 100644 --- a/proxy-lib-l4-macos/src/handler.rs +++ b/proxy-lib-l4-macos/src/handler.rs @@ -39,7 +39,40 @@ pub struct FlowHandler { impl FlowHandler { async fn try_new(ctx: TransparentProxyServiceContext) -> Result { - let tcp_mitm_service = crate::tcp::TcpMitmService::try_new(ctx).await?; + let executor = ctx.executor.clone(); + let (tcp_mitm_service, ca_state) = crate::tcp::TcpMitmService::try_new(ctx).await?; + + let cfg = tcp_mitm_service.proxy_config(); + if cfg.xpc_service_name.is_some() + || cfg.container_signing_identifier.is_some() + || cfg.container_team_identifier.is_some() + { + // All XPC identity fields must be set together — the inner spawn enforces + // that and fails closed if either is missing. We swallow the + // result here so a misconfigured XPC config does not bring the + // whole transparent proxy down: TLS interception keeps working, + // only `generate-ca-crt` / `commit-ca-crt` become unavailable. + if let Err(err) = crate::xpc_server::spawn( + cfg.xpc_service_name.clone(), + cfg.container_signing_identifier.clone(), + cfg.container_team_identifier.clone(), + ca_state, + executor, + ) { + tracing::error!( + error = %err, + "failed to spawn aikido L4 sysext XPC server; CA generate/commit \ + routes will be unavailable" + ); + } + } else { + tracing::warn!( + "xpc_service_name, container_signing_identifier, and \ + container_team_identifier are all unset in opaque engine config; XPC \ + server not spawned. `generate-ca-crt` / `commit-ca-crt` will not be \ + available until all required identity fields are provided." + ); + } let proxy_config = TransparentProxyConfig::new().with_rules(vec![ TransparentProxyNetworkRule::any().with_protocol(TransparentProxyRuleProtocol::Tcp), diff --git a/proxy-lib-l4-macos/src/lib.rs b/proxy-lib-l4-macos/src/lib.rs index 779b28ab..5e037c93 100644 --- a/proxy-lib-l4-macos/src/lib.rs +++ b/proxy-lib-l4-macos/src/lib.rs @@ -10,8 +10,11 @@ static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; mod config; mod handler; mod init; +mod state; mod tcp; +mod tls; mod utils; +mod xpc_server; transparent_proxy_ffi! { init = self::init::init, diff --git a/proxy-lib-l4-macos/src/state.rs b/proxy-lib-l4-macos/src/state.rs new file mode 100644 index 00000000..95fac059 --- /dev/null +++ b/proxy-lib-l4-macos/src/state.rs @@ -0,0 +1,86 @@ +//! Shared, atomically swappable MITM CA state for the L4 transparent proxy +//! sysext. +//! +//! The active CA (the one used for real TLS interception) and any +//! freshly-minted pending CA live together inside a single `ArcSwap`, +//! so we can roll either piece forward without disrupting in-flight flows. +//! +//! Lifecycle: +//! +//! - **Boot.** [`tls::load_or_create_active_ca`] returns the active pair — +//! loaded from the SE-encrypted system keychain, or freshly minted + +//! persisted on first boot, or (deprecated) lifted from opaque config when +//! a legacy CA is forwarded by the container app. +//! - **Generate (XPC).** A fresh CA is minted in memory and parked in +//! [`LiveCa::pending`]. The active relay is untouched, but the hijack +//! endpoint already serves the pending PEM so callers can fetch the next +//! cert and install trust for it. +//! - **Commit (XPC).** Pending is persisted to the SE-encrypted system +//! keychain; only after that succeeds do we rebuild the relay and swap +//! the active CA. The previous active DER is returned to the caller so +//! downstream trust stores can drop it. +//! +//! Pending state is in-memory only. If the sysext restarts before commit, the +//! caller has to re-issue `generate-ca-crt`; persisting partially-completed +//! rotations on disk would only buy us trouble. + +use std::sync::Arc; + +use arc_swap::ArcSwap; +use rama::{ + bytes::Bytes, + tls::boring::{ + core::{ + pkey::{PKey, Private}, + x509::X509, + }, + proxy::{ + TlsMitmRelay, + cert_issuer::{CachedBoringMitmCertIssuer, InMemoryBoringMitmCertIssuer}, + }, + }, +}; + +/// `TlsMitmRelay` flavour used by this sysext: in-memory issuer with leaf +/// caching. Matches the original `TcpTlsMitmRelay` alias inside `tcp.rs`. +pub(crate) type AikidoTlsMitmRelay = + TlsMitmRelay>; + +/// CA pair currently used for TLS interception. +#[derive(Clone)] +pub(crate) struct ActiveCa { + pub(crate) relay: AikidoTlsMitmRelay, + pub(crate) cert_pem: Bytes, + pub(crate) cert_der: Bytes, +} + +/// CA pair minted by `generate-ca-crt` but not yet active. Cloning is cheap +/// because boring's `X509` / `PKey` are reference-counted internally. +#[derive(Clone)] +pub(crate) struct PendingCa { + pub(crate) cert: X509, + pub(crate) key: PKey, + pub(crate) cert_pem: Bytes, + pub(crate) cert_der: Bytes, +} + +/// Atomically swappable view of "active CA + optional pending CA". +#[derive(Clone)] +pub(crate) struct LiveCa { + pub(crate) active: Arc, + pub(crate) pending: Option>, +} + +impl LiveCa { + /// PEM bytes the hijack endpoint should serve. Pending wins when present + /// so callers fetching the cert get the *next* one to trust; once the + /// rotation commits, the active and pending PEMs are the same. + pub(crate) fn hijack_cert_pem(&self) -> &Bytes { + match &self.pending { + Some(p) => &p.cert_pem, + None => &self.active.cert_pem, + } + } +} + +pub(crate) type SharedCaState = Arc>; diff --git a/proxy-lib-l4-macos/src/tcp.rs b/proxy-lib-l4-macos/src/tcp.rs index 49fb8207..2ce6ee52 100644 --- a/proxy-lib-l4-macos/src/tcp.rs +++ b/proxy-lib-l4-macos/src/tcp.rs @@ -1,5 +1,9 @@ +use std::{convert::Infallible, path::PathBuf, sync::Arc, time::Duration}; + +use arc_swap::ArcSwap; use rama::{ Layer, Service, + bytes::Bytes, combinators::Either, error::{BoxError, ErrorContext as _, ErrorExt as _, extra::OpaqueError}, extensions::ExtensionsRef, @@ -33,16 +37,13 @@ use rama::{ proxy::socks5::{proxy::mitm::Socks5MitmRelayService, server::Socks5PeekRouter}, rt::Executor, telemetry::tracing, - tls::boring::proxy::{ - TlsMitmRelay, - cert_issuer::{ - BoringMitmCertIssuer, CachedBoringMitmCertIssuer, InMemoryBoringMitmCertIssuer, - }, - }, + tls::boring::proxy::{TlsMitmRelay, cert_issuer::BoringMitmCertIssuer}, }; -use std::{convert::Infallible, path::PathBuf, sync::Arc, time::Duration}; -use crate::config::ProxyConfig; +use crate::{ + config::ProxyConfig, + state::{LiveCa, SharedCaState}, +}; use safechain_proxy_lib::{ endpoint_protection::remote_app_passthrough_list::PassthroughMatchContext, http::{ @@ -52,51 +53,51 @@ use safechain_proxy_lib::{ ws_relay::WebSocketMitmRelayService, }, storage, - tls::{RootCaKeyPair, mitm_relay_policy::TlsMitmRelayPolicyLayer}, + tls::mitm_relay_policy::TlsMitmRelayPolicyLayer, utils::token::AgentIdentity, }; -type TcpTlsMitmRelay = TlsMitmRelay>; - struct TcpMitmServiceInner { proxy_config: ProxyConfig, tls_mitm_relay_policy: TlsMitmRelayPolicyLayer, - tls_mitm_relay: TcpTlsMitmRelay, firewall: Firewall, - ca_crt_pem_bytes: &'static [u8], + state: SharedCaState, } #[derive(Clone)] pub(super) struct TcpMitmService(Arc); impl TcpMitmService { - pub(super) async fn try_new(ctx: TransparentProxyServiceContext) -> Result { + pub(super) async fn try_new( + ctx: TransparentProxyServiceContext, + ) -> Result<(Self, SharedCaState), BoxError> { let proxy_config = ProxyConfig::from_opaque_config(ctx.opaque_config()) .context("decode proxy config (json)")?; - let Some((ca_crt_pem, ca_key_pem)) = proxy_config - .ca_cert_pem - .as_deref() - .zip(proxy_config.ca_key_pem.as_deref()) - else { - return Err( - OpaqueError::from_static_str("CA crt or key missing in Opaque Config") - .into_box_error(), - ); + let legacy_pems = match ( + proxy_config.ca_cert_pem.as_deref(), + proxy_config.ca_key_pem.as_deref(), + ) { + (Some(cert), Some(key)) => Some((cert, key)), + (Some(_), None) | (None, Some(_)) => { + return Err(OpaqueError::from_static_str( + "legacy MITM CA passthrough requires both `ca_cert_pem` and `ca_key_pem`", + ) + .into_box_error()); + } + (None, None) => None, + }; + + let active = crate::tls::load_or_create_active_ca(legacy_pems) + .context("load or mint active MITM CA")?; + let live = LiveCa { + active: Arc::new(active), + pending: None, }; + let state: SharedCaState = Arc::new(ArcSwap::from_pointee(live)); let data_path = crate::utils::storage::storage_dir().context("(app) data path is missing")?; - let root_ca = RootCaKeyPair::try_form_pem(ca_crt_pem, ca_key_pem) - .context("load config-provided ca crt/key pair")?; - - let ca_crt_pem_bytes: &[u8] = root_ca - .certificate() - .to_pem() - .context("convert cert to pem")? - .leak(); - - let (ca_crt, ca_key) = root_ca.into_pair(); let guard = ctx .executor @@ -117,13 +118,14 @@ impl TcpMitmService { tracing::debug!("creating tcp mitm state for transparent proxy extension"); - Ok(Self(Arc::new(TcpMitmServiceInner { + let service = Self(Arc::new(TcpMitmServiceInner { proxy_config, tls_mitm_relay_policy: TlsMitmRelayPolicyLayer::new(firewall.clone()), - tls_mitm_relay: TlsMitmRelay::new_cached_in_memory(ca_crt, ca_key), firewall, - ca_crt_pem_bytes, - }))) + state: state.clone(), + })); + + Ok((service, state)) } pub(super) fn proxy_config(&self) -> &ProxyConfig { @@ -172,12 +174,20 @@ impl TcpMitmService { Ingress: Io + Unpin + ExtensionsRef, Egress: Io + Unpin + ExtensionsRef, { + // Snapshot the live CA state at flow-build time. Pending rotations + // surface to new flows on the next bridge build; in-flight flows keep + // serving with whatever they captured. This matches the rama + // transparent-proxy demo's approach. + let live: Arc = self.0.state.load_full(); + let active_relay = live.active.relay.clone(); + let hijack_pem = live.hijack_cert_pem().clone(); + new_tcp_service_inner( exec, self.0.tls_mitm_relay_policy.clone(), - self.0.tls_mitm_relay.clone(), + active_relay, self.0.firewall.clone(), - self.0.ca_crt_pem_bytes, + hijack_pem, within_connect_tunnel, tls_peek_duration, http_peek_duration, @@ -191,7 +201,7 @@ fn new_tcp_service_inner( tls_mitm_relay_policy: TlsMitmRelayPolicyLayer, tls_mitm_relay: TlsMitmRelay, firewall: Firewall, - ca_crt_pem_bytes: &'static [u8], + hijack_pem: Bytes, within_connect_tunnel: bool, tls_peek_duration: Duration, http_peek_duration: Duration, @@ -207,7 +217,7 @@ where tls_mitm_relay_policy.clone(), tls_mitm_relay.clone(), firewall, - ca_crt_pem_bytes, + hijack_pem, within_connect_tunnel, tls_peek_duration, http_peek_duration, @@ -241,7 +251,7 @@ fn http_relay_middleware( tls_mitm_relay_policy: TlsMitmRelayPolicyLayer, tls_mitm_relay: TlsMitmRelay, firewall: Firewall, - ca_crt_pem_bytes: &'static [u8], + hijack_pem: Bytes, within_connect_tunnel: bool, tls_peek_duration: Duration, http_peek_duration: Duration, @@ -264,7 +274,7 @@ where tls_mitm_relay_policy, tls_mitm_relay, firewall.clone(), - ca_crt_pem_bytes, + hijack_pem.clone(), true, tls_peek_duration, http_peek_duration, @@ -277,7 +287,7 @@ where StreamCompressionLayer::new().with_compress_predicate(MirrorDecompressed::new()), HijackLayer::new( DomainMatcher::exact(HIJACK_DOMAIN), - Arc::new(hijack::new_service(ca_crt_pem_bytes, firewall.clone())), + Arc::new(hijack::new_service(hijack_pem, firewall.clone())), ), firewall, MapResponseBodyLayer::new_boxed_streaming_body(), diff --git a/proxy-lib-l4-macos/src/tls.rs b/proxy-lib-l4-macos/src/tls.rs new file mode 100644 index 00000000..c76531ba --- /dev/null +++ b/proxy-lib-l4-macos/src/tls.rs @@ -0,0 +1,331 @@ +//! MITM CA generation, persistence, and migration for the L4 transparent +//! proxy sysext. +//! +//! ## Storage model +//! +//! The active CA is encrypted with a Secure-Enclave-protected P-256 key and +//! stored in the macOS **System Keychain** (`/Library/Keychains/System.keychain`). +//! Three entries make up the bundle: +//! +//! | Service | Purpose | +//! |---|---| +//! | `aikido-l4-mitm-ca-se-key` | SE key `dataRepresentation` (envelope) | +//! | `aikido-l4-mitm-ca-crt` | SE-encrypted CA cert PEM | +//! | `aikido-l4-mitm-ca-key` | SE-encrypted CA key PEM | +//! +//! The Secure Enclave is **mandatory** for the SE-encrypted store. If the +//! host has no usable SE and we'd have to mint + persist, [`load_or_create_active_ca`] +//! hard-errors rather than fall back to plaintext keychain storage. The +//! legacy passthrough below is the one deliberate exception, scoped to +//! existing graceful-migration installs. +//! +//! ## Legacy passthrough (graceful period) +//! +//! Older container builds generated the CA themselves and forwarded the +//! plaintext PEMs through the opaque config. Those PEMs are considered +//! polluted (they passed through an insecure plain-text boundary) and we +//! must NOT persist them in the SE-encrypted store. They are used **run-only** +//! and **only when the SE-encrypted store is empty** — once a `commit-ca-crt` +//! lands, the SE-backed CA wins on every subsequent boot regardless of what +//! the container forwards (see [`load_or_create_active_ca`] for precedence +//! details). The caller is expected to issue `generate-ca-crt` + +//! `commit-ca-crt` to retire the legacy CA at its earliest convenience. +//! +//! ## Module shape +//! +//! Two entry points: +//! +//! - [`load_or_create_active_ca`] — boot path. Returns an `ActiveCa` ready to +//! be wrapped in [`crate::state::LiveCa`]. +//! - [`generate_pending_ca`] — XPC `generate-ca-crt` path. Mints fresh +//! key + cert in memory; **does not** touch the keychain. +//! +//! Plus [`persist_pending_ca`], used by the XPC `commit-ca-crt` route to +//! encrypt and store a [`crate::state::PendingCa`] before the relay swap. + +use rama::{ + bytes::Bytes, + error::{BoxError, ErrorContext as _, ErrorExt as _, extra::OpaqueError}, + net::{ + address::Domain, + apple::networkextension::system_keychain::{ + self, + secure_enclave::{ + SecureEnclaveAccessibility, SecureEnclaveKey, is_available as se_is_available, + }, + }, + tls::server::SelfSignedData, + }, + telemetry::tracing, + tls::boring::{ + core::{ + pkey::{PKey, Private}, + x509::X509, + }, + proxy::TlsMitmRelay, + server::utils::self_signed_server_auth_gen_ca, + }, +}; + +use crate::state::{ActiveCa, PendingCa}; + +const CA_ACCOUNT: &str = "com.aikido.endpoint.proxy.l4"; +const CA_SERVICE_CERT: &str = "aikido-l4-mitm-ca-crt"; +const CA_SERVICE_KEY: &str = "aikido-l4-mitm-ca-key"; +const SE_SERVICE_KEY: &str = "aikido-l4-mitm-ca-se-key"; + +const CA_COMMON_NAME: &str = "aikido-l4-mitm-ca.localhost"; +const CA_ORG_NAME: &str = "Aikido Endpoint L4 Proxy Root CA"; + +/// Boot-path resolver for the active MITM CA. +/// +/// Precedence is **SE-backed first, legacy second**: as soon as a +/// `commit-ca-crt` lands an SE-encrypted CA, every subsequent boot picks +/// it up regardless of what the container forwards in `legacy_pems`. That +/// keeps a successful commit durable even if the container's best-effort +/// legacy-keychain cleanup later fails — the legacy entry becomes dead +/// weight, never re-promoted. +/// +/// `legacy_pems` carries `(cert_pem, key_pem)` forwarded through the opaque +/// config by the container app for the graceful-migration period. They are +/// used **only** when the SE-encrypted system keychain is empty, **for the +/// run only**, and are *never* written to the SE store. +/// +/// On first boot (or after `delete-ca-crt`) with no legacy material, a fresh +/// CA is minted and persisted via Secure Enclave. +/// +/// Hard-errors when the host hardware does not expose a usable Secure +/// Enclave **and** there is nothing to load — i.e. when we'd actually need +/// to mint and persist. The legacy-passthrough fallback is the one +/// deliberate exception: an existing graceful-migration install on +/// SE-less hardware keeps working until it can be rotated out elsewhere. +pub(crate) fn load_or_create_active_ca( + legacy_pems: Option<(&str, &str)>, +) -> Result { + // SE-backed CA wins whenever it exists, no matter what the container + // forwarded. Only probe the system keychain when SE is actually + // available — without SE we can't decrypt anything stored there. + if se_is_available() + && let Some((cert, key)) = try_load_se_encrypted_ca()? + { + tracing::info!( + cert_service = CA_SERVICE_CERT, + key_service = CA_SERVICE_KEY, + se_service = SE_SERVICE_KEY, + account = CA_ACCOUNT, + "loaded MITM CA from SE-encrypted system keychain" + ); + return active_ca_from_pair(cert, key); + } + + if let Some((cert_pem, key_pem)) = legacy_pems { + tracing::warn!( + "DEPRECATED: using legacy MITM CA forwarded by the container app via opaque \ + config. The legacy CA will NOT be persisted in the SE-encrypted system keychain. \ + Caller should rotate it out via `generate-ca-crt` + `commit-ca-crt` as soon as \ + possible." + ); + let cert = X509::from_pem(cert_pem.as_bytes()) + .context("parse legacy MITM CA cert PEM from opaque config")?; + let key = PKey::private_key_from_pem(key_pem.as_bytes()) + .context("parse legacy MITM CA key PEM from opaque config")?; + return active_ca_from_pair(cert, key); + } + + require_secure_enclave()?; + + tracing::info!( + "no MITM CA found in SE-encrypted system keychain; minting + persisting a fresh one" + ); + let pending = generate_pending_ca()?; + persist_pending_ca(&pending)?; + active_ca_from_pending(&pending) +} + +/// Mint a fresh MITM CA key + cert in memory. +/// +/// Does **not** touch the keychain. The returned [`PendingCa`] is what the +/// XPC `generate-ca-crt` route hands back to callers and parks in +/// [`crate::state::LiveCa::pending`]. +pub(crate) fn generate_pending_ca() -> Result { + let (cert, key) = self_signed_server_auth_gen_ca(&SelfSignedData { + organisation_name: Some(CA_ORG_NAME.to_owned()), + common_name: Some(Domain::from_static(CA_COMMON_NAME)), + ..Default::default() + }) + .context("generate self-signed MITM CA")?; + + let cert_pem = cert.to_pem().context("encode MITM CA cert to PEM")?; + let cert_der = cert.to_der().context("encode MITM CA cert to DER")?; + + Ok(PendingCa { + cert, + key, + cert_pem: Bytes::from(cert_pem), + cert_der: Bytes::from(cert_der), + }) +} + +/// Encrypt + persist a pending CA in the SE-encrypted system keychain. +/// +/// Used by the XPC `commit-ca-crt` route immediately before swapping the +/// active relay. Any failure here aborts the rotation: the old CA stays +/// active and the keychain is best-effort cleaned of partial state so the +/// next attempt starts from a clean slate. +pub(crate) fn persist_pending_ca(pending: &PendingCa) -> Result<(), BoxError> { + require_secure_enclave()?; + + let key_pem = pending + .key + .private_key_to_pem_pkcs8() + .context("encode MITM CA private key to PEM (PKCS#8)")?; + + let se_key = SecureEnclaveKey::create(SecureEnclaveAccessibility::Always) + .context("mint Secure Enclave P-256 key for MITM CA")?; + + let cert_envelope = se_key + .encrypt(&pending.cert_pem) + .context("encrypt MITM CA cert PEM with Secure Enclave")?; + let key_envelope = se_key + .encrypt(&key_pem) + .context("encrypt MITM CA key PEM with Secure Enclave")?; + + if let Err(err) = store_all(se_key.data_representation(), &cert_envelope, &key_envelope) { + tracing::error!( + error = %err, + "failed to persist SE-encrypted MITM CA in system keychain; wiping partial state" + ); + let _ = wipe_se_encrypted_ca(); + return Err(err); + } + + tracing::info!( + cert_service = CA_SERVICE_CERT, + key_service = CA_SERVICE_KEY, + se_service = SE_SERVICE_KEY, + account = CA_ACCOUNT, + cert_envelope_len = cert_envelope.len(), + key_envelope_len = key_envelope.len(), + se_blob_len = se_key.data_representation().len(), + "persisted SE-encrypted MITM CA in system keychain" + ); + + Ok(()) +} + +fn store_all(se_blob: &[u8], cert_envelope: &[u8], key_envelope: &[u8]) -> Result<(), BoxError> { + system_keychain::store_secret(SE_SERVICE_KEY, CA_ACCOUNT, se_blob) + .context("store Secure Enclave key blob in system keychain")?; + system_keychain::store_secret(CA_SERVICE_CERT, CA_ACCOUNT, cert_envelope) + .context("store SE-encrypted MITM CA cert in system keychain")?; + system_keychain::store_secret(CA_SERVICE_KEY, CA_ACCOUNT, key_envelope) + .context("store SE-encrypted MITM CA key in system keychain")?; + Ok(()) +} + +fn try_load_se_encrypted_ca() -> Result)>, BoxError> { + let se_blob = load_secret(SE_SERVICE_KEY)?; + let cert_blob = load_secret(CA_SERVICE_CERT)?; + let key_blob = load_secret(CA_SERVICE_KEY)?; + + let presence = (se_blob.is_some(), cert_blob.is_some(), key_blob.is_some()); + + let (Some(se_blob), Some(cert_blob), Some(key_blob)) = (se_blob, cert_blob, key_blob) else { + let present_count = u8::from(presence.0) + u8::from(presence.1) + u8::from(presence.2); + if present_count > 0 { + tracing::warn!( + se_blob_present = presence.0, + cert_blob_present = presence.1, + key_blob_present = presence.2, + "incomplete SE-encrypted MITM CA state in system keychain; wiping and \ + regenerating" + ); + let _ = wipe_se_encrypted_ca(); + } + return Ok(None); + }; + + let se_key = SecureEnclaveKey::from_data_representation(se_blob); + match decrypt_pair(&se_key, &cert_blob, &key_blob) { + Ok(pair) => Ok(Some(pair)), + Err(err) => { + tracing::error!( + error = %err, + "failed to decrypt SE-encrypted MITM CA from system keychain; wiping all \ + entries and regenerating" + ); + let _ = wipe_se_encrypted_ca(); + Ok(None) + } + } +} + +fn decrypt_pair( + se_key: &SecureEnclaveKey, + cert_envelope: &[u8], + key_envelope: &[u8], +) -> Result<(X509, PKey), BoxError> { + let cert_pem = se_key + .decrypt(cert_envelope) + .context("decrypt MITM CA cert with Secure Enclave")?; + let key_pem = se_key + .decrypt(key_envelope) + .context("decrypt MITM CA key with Secure Enclave")?; + let cert = X509::from_pem(&cert_pem).context("parse decrypted MITM CA cert PEM")?; + let key = PKey::private_key_from_pem(&key_pem).context("parse decrypted MITM CA key PEM")?; + Ok((cert, key)) +} + +fn load_secret(service: &str) -> Result>, BoxError> { + system_keychain::load_secret(service, CA_ACCOUNT) + .with_context(|| format!("load `{service}` from system keychain")) +} + +fn wipe_se_encrypted_ca() -> Result<(), BoxError> { + let mut last_err: Option = None; + for service in [SE_SERVICE_KEY, CA_SERVICE_CERT, CA_SERVICE_KEY] { + if let Err(err) = system_keychain::delete_secret(service, CA_ACCOUNT) { + let boxed: BoxError = Box::new(err); + last_err = Some( + Result::<(), BoxError>::Err(boxed) + .with_context(|| format!("delete `{service}` from system keychain")) + .unwrap_err(), + ); + } + } + match last_err { + Some(err) => Err(err), + None => Ok(()), + } +} + +fn require_secure_enclave() -> Result<(), BoxError> { + if se_is_available() { + return Ok(()); + } + Err(OpaqueError::from_static_str( + "Secure Enclave unavailable on this Mac; the L4 transparent proxy refuses to \ + operate without SE-protected MITM CA storage. Affected hardware: Intel Macs \ + without a T2 chip and any host where the SE is otherwise disabled. Contact \ + Aikido support for next steps.", + ) + .into_box_error()) +} + +fn active_ca_from_pair(cert: X509, key: PKey) -> Result { + let cert_pem = cert.to_pem().context("encode active MITM CA cert to PEM")?; + let cert_der = cert.to_der().context("encode active MITM CA cert to DER")?; + Ok(ActiveCa { + relay: TlsMitmRelay::new_cached_in_memory(cert, key), + cert_pem: Bytes::from(cert_pem), + cert_der: Bytes::from(cert_der), + }) +} + +fn active_ca_from_pending(pending: &PendingCa) -> Result { + Ok(ActiveCa { + relay: TlsMitmRelay::new_cached_in_memory(pending.cert.clone(), pending.key.clone()), + cert_pem: pending.cert_pem.clone(), + cert_der: pending.cert_der.clone(), + }) +} diff --git a/proxy-lib-l4-macos/src/xpc_server.rs b/proxy-lib-l4-macos/src/xpc_server.rs new file mode 100644 index 00000000..0fdd53ec --- /dev/null +++ b/proxy-lib-l4-macos/src/xpc_server.rs @@ -0,0 +1,323 @@ +//! XPC routes for driving MITM CA generation + commit from the container app. +//! +//! Two routes, deliberately small and explicit: +//! +//! - `generateCaCrt:withReply:` — the sysext mints a fresh CA in memory and +//! parks it in [`crate::state::LiveCa::pending`]. The active TLS relay is +//! left alone, but the hijack endpoint immediately starts serving the new +//! PEM so callers can fetch the next cert and install trust for it. The +//! reply carries the new DER (base64) so callers that talk XPC directly +//! don't have to fetch from hijack. +//! - `commitCaCrt:withReply:` — fails if no pending CA is parked. Otherwise +//! persists the pending CA in the SE-encrypted system keychain (fail-fast +//! if persist fails); only after persist succeeds is the active relay +//! atomically swapped. The reply carries the previous active CA's DER +//! (base64), so callers can drop its trust. +//! +//! There is intentionally **no** install/uninstall route here: the container +//! app handles trust storage outside of XPC. There is also no "delete CA" +//! route — that lives entirely on the container side because the relevant +//! keychain entries (legacy data-protection + System Keychain items) are +//! addressable from the user-side process. +//! +//! Pending state lives only in memory. If the sysext restarts before commit, +//! the caller has to re-issue `generate-ca-crt` — see [`crate::state`] for +//! the rationale. +//! +//! The listener is pinned to the container app's exact code identity via +//! [`PeerSecurityRequirement::CodeSigning`]: exact bundle identifier and +//! exact Apple Developer team. We refuse to bind when either value is +//! missing from the engine config — failing closed is safer than exposing +//! the routes to any other process on the host. + +use std::sync::Arc; + +use base64::Engine as _; +use rama::{ + bytes::Bytes, + error::{BoxError, ErrorContext as _, ErrorExt as _, extra::OpaqueError}, + net::apple::xpc::{ + PeerSecurityRequirement, XpcListener, XpcListenerConfig, XpcMessageRouter, XpcServer, + }, + rt::Executor, + service::service_fn, + telemetry::tracing, + utils::str::arcstr::ArcStr, +}; +use serde::{Deserialize, Serialize}; + +use crate::state::{ActiveCa, LiveCa, PendingCa, SharedCaState}; + +#[derive(Debug, Default, Deserialize)] +struct EmptyRequest {} + +/// Reply for `generateCaCrt:withReply:` and `commitCaCrt:withReply:`. +#[derive(Debug, Serialize)] +struct CaCommandReply { + ok: bool, + error: Option, + /// `generateCaCrt`: DER of the freshly minted (pending) CA. + /// `commitCaCrt`: DER of the previous active CA, if any. + cert_der_b64: Option, +} + +impl CaCommandReply { + fn ok_with_cert(cert_der: &[u8]) -> Self { + Self { + ok: true, + error: None, + cert_der_b64: Some(base64::engine::general_purpose::STANDARD.encode(cert_der)), + } + } + + fn ok_without_cert() -> Self { + Self { + ok: true, + error: None, + cert_der_b64: None, + } + } + + fn err(err: &BoxError) -> Self { + Self { + ok: false, + error: Some(format!("{err:#}")), + cert_der_b64: None, + } + } +} + +/// Spawn the sysext's XPC listener. +/// +/// `service_name` is the value declared as `NEMachServiceName` in the +/// extension's `Info.plist`, forwarded by the container app through the +/// opaque engine config. `container_signing_identifier` is the container +/// app's `Bundle.main.bundleIdentifier`; `container_team_identifier` is the +/// Apple Developer team identifier derived by the container app. +/// +/// Any required argument missing or empty is a fail-closed condition: the +/// listener is **not** bound, which means `generate-ca-crt` / +/// `commit-ca-crt` calls from the container will fail loudly. +pub(crate) fn spawn( + service_name: Option, + container_signing_identifier: Option, + container_team_identifier: Option, + state: SharedCaState, + executor: Executor, +) -> Result<(), BoxError> { + let service_name = + service_name + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| -> BoxError { + tracing::error!( + "xpc server: `xpc_service_name` is missing or empty in opaque engine config; \ + refusing to bind XPC listener (fail-closed)." + ); + OpaqueError::from_static_str("xpc server: missing xpc_service_name (fail-closed)") + .into_box_error() + })?; + + let signing_identifier = container_signing_identifier + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| -> BoxError { + tracing::error!( + "xpc server: `container_signing_identifier` is missing or empty in opaque \ + engine config; refusing to bind XPC listener (fail-closed). Set it from \ + the container app's `Bundle.main.bundleIdentifier`." + ); + OpaqueError::from_static_str( + "xpc server: missing container_signing_identifier (fail-closed)", + ) + .into_box_error() + })?; + + let team_identifier = container_team_identifier + .filter(|s| !s.trim().is_empty()) + .ok_or_else(|| -> BoxError { + tracing::error!( + "xpc server: `container_team_identifier` is missing or empty in opaque \ + engine config; refusing to bind XPC listener (fail-closed)." + ); + OpaqueError::from_static_str( + "xpc server: missing container_team_identifier (fail-closed)", + ) + .into_box_error() + })?; + + let requirement = + build_peer_code_signing_requirement(signing_identifier.as_str(), team_identifier.as_str())?; + + tracing::info!( + %service_name, + %signing_identifier, + %team_identifier, + "xpc server: start config+spawn (peer pinned to exact team + bundle identifier)" + ); + + let config = XpcListenerConfig::new(service_name.clone()) + .with_peer_requirement(PeerSecurityRequirement::CodeSigning(requirement)); + + let router = XpcMessageRouter::new() + .with_typed_route::( + "generateCaCrt:withReply:", + service_fn({ + let state = state.clone(); + move |_req: EmptyRequest| { + let state = state.clone(); + async move { + tracing::info!("xpc server: generateCaCrt invoked"); + let reply = match generate_into_pending(&state) { + Ok(der) => { + tracing::info!( + der_len = der.len(), + "xpc server: generateCaCrt succeeded — pending CA parked" + ); + CaCommandReply::ok_with_cert(&der) + } + Err(err) => { + tracing::error!(error = %err, "xpc server: generateCaCrt failed"); + CaCommandReply::err(&err) + } + }; + Ok::<_, BoxError>(reply) + } + } + }), + ) + .with_typed_route::( + "commitCaCrt:withReply:", + service_fn({ + let state = state; + move |_req: EmptyRequest| { + let state = state.clone(); + async move { + tracing::info!("xpc server: commitCaCrt invoked"); + let reply = match commit_pending(&state) { + Ok(previous_der) => { + tracing::info!( + previous_present = previous_der.is_some(), + "xpc server: commitCaCrt succeeded — active CA swapped" + ); + match previous_der { + Some(der) => CaCommandReply::ok_with_cert(&der), + None => CaCommandReply::ok_without_cert(), + } + } + Err(err) => { + tracing::error!(error = %err, "xpc server: commitCaCrt failed"); + CaCommandReply::err(&err) + } + }; + Ok::<_, BoxError>(reply) + } + } + }), + ); + + let server = XpcServer::new(router); + + let listener = XpcListener::bind(config) + .context("bind aikido L4 sysext xpc listener") + .with_context_debug_field("serviceName", || service_name.clone())?; + + let exec_for_loop = executor.clone(); + executor.spawn_cancellable_task(async move { + tracing::info!(%service_name, "xpc server: listener active"); + if let Err(err) = server.serve_listener(listener, exec_for_loop).await { + tracing::error!(%service_name, %err, "xpc server: listener exited with error"); + } + }); + + Ok(()) +} + +fn generate_into_pending(state: &SharedCaState) -> Result { + let pending = crate::tls::generate_pending_ca().context("generate pending MITM CA")?; + let der = pending.cert_der.clone(); + + state.rcu(|cur| LiveCa { + active: cur.active.clone(), + pending: Some(Arc::new(pending.clone())), + }); + + Ok(der) +} + +fn commit_pending(state: &SharedCaState) -> Result, BoxError> { + let cur = state.load_full(); + let Some(pending) = cur.pending.as_ref().cloned() else { + return Err(OpaqueError::from_static_str( + "no pending MITM CA to commit; call generateCaCrt first", + ) + .into_box_error()); + }; + + crate::tls::persist_pending_ca(&pending).context("persist pending MITM CA")?; + + let new_active = build_active_from_pending(&pending); + Ok(promote_committed_pending(state, &pending, new_active)) +} + +fn promote_committed_pending( + state: &SharedCaState, + committed_pending: &Arc, + new_active: Arc, +) -> Option { + let mut previous_der: Option = None; + state.rcu(|live| { + // Hold on to whatever was active when this rcu closure ran. rcu may + // re-run, so we capture every time and keep the latest one — by the + // time the swap actually lands, this will be the cert we displaced. + previous_der = Some(live.active.cert_der.clone()); + // Preserve a newer pending CA that may have been generated while this + // commit was persisting the older one. Only clear `pending` when the + // slot still points at the CA being promoted. + let pending = match live.pending.as_ref() { + Some(current) if Arc::ptr_eq(current, committed_pending) => None, + Some(current) => Some(current.clone()), + None => None, + }; + LiveCa { + active: new_active.clone(), + pending, + } + }); + previous_der +} + +fn build_active_from_pending(pending: &PendingCa) -> Arc { + Arc::new(ActiveCa { + relay: rama::tls::boring::proxy::TlsMitmRelay::new_cached_in_memory( + pending.cert.clone(), + pending.key.clone(), + ), + cert_pem: pending.cert_pem.clone(), + cert_der: pending.cert_der.clone(), + }) +} + +fn build_peer_code_signing_requirement( + signing_identifier: &str, + team_identifier: &str, +) -> Result { + let signing_identifier = + sanitize_requirement_atom("container_signing_identifier", signing_identifier)?; + let team_identifier = sanitize_requirement_atom("container_team_identifier", team_identifier)?; + Ok(ArcStr::from(format!( + "anchor apple generic and certificate leaf[subject.OU] = \"{team_identifier}\" and identifier \"{signing_identifier}\"" + ))) +} + +fn sanitize_requirement_atom<'a>(field: &'static str, value: &'a str) -> Result<&'a str, BoxError> { + if value.contains('"') || value.contains('\\') { + return Err(OpaqueError::from_static_str( + "xpc server: invalid code signing requirement component", + ) + .context_field("field", field)); + } + Ok(value) +} + +#[cfg(test)] +#[path = "xpc_server_tests.rs"] +mod tests; diff --git a/proxy-lib-l4-macos/src/xpc_server_tests.rs b/proxy-lib-l4-macos/src/xpc_server_tests.rs new file mode 100644 index 00000000..580034aa --- /dev/null +++ b/proxy-lib-l4-macos/src/xpc_server_tests.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; + +use super::{ + build_active_from_pending, build_peer_code_signing_requirement, promote_committed_pending, +}; +use crate::{ + state::{LiveCa, SharedCaState}, + tls::generate_pending_ca, +}; + +#[test] +fn promote_committed_pending_preserves_newer_pending_ca() { + let active_pending = generate_pending_ca().expect("generate initial active ca"); + let committed_pending = Arc::new(generate_pending_ca().expect("generate committed pending ca")); + let newer_pending = Arc::new(generate_pending_ca().expect("generate newer pending ca")); + + let state: SharedCaState = Arc::new(ArcSwap::from_pointee(LiveCa { + active: build_active_from_pending(&active_pending), + pending: Some(committed_pending.clone()), + })); + + state.rcu(|live| LiveCa { + active: live.active.clone(), + pending: Some(newer_pending.clone()), + }); + + let previous_der = promote_committed_pending( + &state, + &committed_pending, + build_active_from_pending(&committed_pending), + ) + .expect("previous active der should be captured"); + + let live = state.load_full(); + assert_eq!(live.active.cert_der, committed_pending.cert_der); + assert_eq!(previous_der, active_pending.cert_der); + let still_pending = live + .pending + .clone() + .expect("newer pending ca should be preserved"); + assert!(Arc::ptr_eq(&still_pending, &newer_pending)); +} + +#[test] +fn build_peer_code_signing_requirement_pins_team_and_identifier() { + let requirement = + build_peer_code_signing_requirement("com.aikido.endpoint.proxy.l4.dev", "7VPF8GD6J4") + .expect("requirement should build"); + + assert!(requirement.contains("anchor apple generic")); + assert!(requirement.contains("identifier \"com.aikido.endpoint.proxy.l4.dev\"")); + assert!(requirement.contains("certificate leaf[subject.OU] = \"7VPF8GD6J4\"")); +} diff --git a/proxy-lib/src/http/service/hijack.rs b/proxy-lib/src/http/service/hijack.rs index 19ae6532..1566fe75 100644 --- a/proxy-lib/src/http/service/hijack.rs +++ b/proxy-lib/src/http/service/hijack.rs @@ -2,6 +2,7 @@ use std::convert::Infallible; use rama::{ Service, + bytes::Bytes, http::{ HeaderValue, Request, Response, StatusCode, header::CONTENT_TYPE, @@ -39,14 +40,14 @@ use crate::http::firewall::Firewall; pub const HIJACK_DOMAIN: Domain = Domain::from_static("mitm.ramaproxy.org"); pub fn new_service( - root_ca_pem: &'static [u8], + root_ca_pem: Bytes, firewall: Firewall, ) -> impl Service { Router::new() .with_get("/", Html(STATIC_INDEX_PAGE)) .with_get("/ping", StatusCode::OK) .with_get("/data/root.ca.pem", move || { - let mut resp = root_ca_pem.into_response(); + let mut resp = root_ca_pem.clone().into_response(); resp.headers_mut().insert( CONTENT_TYPE, HeaderValue::from_static("application/x-pem-file"),