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