From ddd403c1b3f247df748c9e6d23a92e7c66ae05c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=85=89=E8=BE=89?= Date: Thu, 22 Oct 2026 11:13:25 +0800 Subject: [PATCH 1/5] feat: add capture certificate injection Resolve the proxy's CA, inspect the presented chain, and pre-flight that the proxy signs with the chosen CA before installing the certificate into the guest trust store. - Proxy probe retrieves the CA and reads the chain through the shared OpenSSL helper, including the CA constraint extension. - The chain verdict is decided from the anchor comparison, and the chosen CA is decided once before the trust object is built. - Certificate record and install orchestration refuse a concurrent install, clean up the guest on failure, and drop the record when it cannot be written. - The injector factory selects the path from the session capability probe: a KernelSU module, or a writable system session with an optional shell-root ramdisk. Writable system sessions can pick the system image tag. - Trust store staging is kept out of later rebuilds, and the APEX store is probed from the zygote listing so apps see the injected entry. - Settings expose certificate import and install state, covered by unit tests, fixtures, an integration script, and documentation. --- Mandroid.xcodeproj/project.pbxproj | 114 +- Mandroid/Settings/SettingsView.swift | 436 ++++- MandroidKit/Capture/CaptureCertificate.swift | 141 ++ .../CaptureCertificateController.swift | 132 ++ .../Capture/CaptureCertificateStore.swift | 29 + .../CertificateAuthorityResolver.swift | 59 + .../Capture/CertificateChainChecker.swift | 110 ++ .../Capture/CertificateInjecting.swift | 47 + .../Capture/CertificateInjectorFactory.swift | 52 + MandroidKit/Capture/GuestTrustStore.swift | 171 ++ .../Capture/KernelSUModuleInjector.swift | 72 + MandroidKit/Capture/ProxyProbe.swift | 88 + .../Capture/WritableSystemInjector.swift | 155 ++ .../Emulator/EmulatorLaunchOptions.swift | 6 + .../Emulator/GuestSessionCapabilities.swift | 37 + MandroidKit/KernelSU/KernelSUPatcher.swift | 41 +- MandroidKit/Runner/RunnerCoordinator.swift | 58 +- MandroidKit/Runner/RunnerSettings.swift | 22 + MandroidKit/SDK/SDKBootstrap.swift | 65 +- .../CaptureCertificateControllerTests.swift | 203 ++ .../CaptureCertificateTests.swift | 36 + .../CertificateAuthorityResolverTests.swift | 59 + .../CertificateChainCheckerTests.swift | 78 + .../CertificateInjectorFactoryTests.swift | 59 + MandroidKitTests/Fixtures/other-ca.pem | 21 + MandroidKitTests/Fixtures/test-ca.pem | 21 + MandroidKitTests/Fixtures/test-leaf.pem | 19 + .../GuestSessionCapabilitiesTests.swift | 72 + MandroidKitTests/GuestTrustStoreTests.swift | 163 ++ .../KernelSUModuleInjectorTests.swift | 128 ++ MandroidKitTests/KernelSUTests.swift | 55 +- MandroidKitTests/ProxyProbeTests.swift | 24 + .../SystemImageSelectionTests.swift | 262 +++ .../WritableSystemInjectorTests.swift | 180 ++ README.md | 21 +- README.zh-CN.md | 93 + Scripts/capture-certificate-test.sh | 625 ++++++ docs/CERTIFICATE-INJECTION-PLAN.md | 1702 +++++++++++++++++ docs/CERTIFICATE-INJECTION.md | 362 ++++ docs/KERNELSU.md | 20 +- 40 files changed, 6005 insertions(+), 33 deletions(-) create mode 100644 MandroidKit/Capture/CaptureCertificate.swift create mode 100644 MandroidKit/Capture/CaptureCertificateController.swift create mode 100644 MandroidKit/Capture/CaptureCertificateStore.swift create mode 100644 MandroidKit/Capture/CertificateAuthorityResolver.swift create mode 100644 MandroidKit/Capture/CertificateChainChecker.swift create mode 100644 MandroidKit/Capture/CertificateInjecting.swift create mode 100644 MandroidKit/Capture/CertificateInjectorFactory.swift create mode 100644 MandroidKit/Capture/GuestTrustStore.swift create mode 100644 MandroidKit/Capture/KernelSUModuleInjector.swift create mode 100644 MandroidKit/Capture/ProxyProbe.swift create mode 100644 MandroidKit/Capture/WritableSystemInjector.swift create mode 100644 MandroidKit/Emulator/GuestSessionCapabilities.swift create mode 100644 MandroidKitTests/CaptureCertificateControllerTests.swift create mode 100644 MandroidKitTests/CaptureCertificateTests.swift create mode 100644 MandroidKitTests/CertificateAuthorityResolverTests.swift create mode 100644 MandroidKitTests/CertificateChainCheckerTests.swift create mode 100644 MandroidKitTests/CertificateInjectorFactoryTests.swift create mode 100644 MandroidKitTests/Fixtures/other-ca.pem create mode 100644 MandroidKitTests/Fixtures/test-ca.pem create mode 100644 MandroidKitTests/Fixtures/test-leaf.pem create mode 100644 MandroidKitTests/GuestSessionCapabilitiesTests.swift create mode 100644 MandroidKitTests/GuestTrustStoreTests.swift create mode 100644 MandroidKitTests/KernelSUModuleInjectorTests.swift create mode 100644 MandroidKitTests/ProxyProbeTests.swift create mode 100644 MandroidKitTests/SystemImageSelectionTests.swift create mode 100644 MandroidKitTests/WritableSystemInjectorTests.swift create mode 100644 README.zh-CN.md create mode 100755 Scripts/capture-certificate-test.sh create mode 100644 docs/CERTIFICATE-INJECTION-PLAN.md create mode 100644 docs/CERTIFICATE-INJECTION.md diff --git a/Mandroid.xcodeproj/project.pbxproj b/Mandroid.xcodeproj/project.pbxproj index 85b5730..4860d1f 100644 --- a/Mandroid.xcodeproj/project.pbxproj +++ b/Mandroid.xcodeproj/project.pbxproj @@ -11,24 +11,34 @@ 042A528ECE7E1A97BEF0C932 /* CatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE44AA57BFC4862E28A97484 /* CatalogTests.swift */; }; 05874F1DF6A7CD0C4834B90C /* DeviceProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = A038F5C78A86CE57B44C749F /* DeviceProfile.swift */; }; 05D232786974587E475E9966 /* DebugHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B147EB246CA2F3A3C58614 /* DebugHooks.swift */; }; + 0C9DE5409AEF3F62E715D872 /* GuestTrustStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 676DB9C812A2C2AFFB56962F /* GuestTrustStoreTests.swift */; }; 0E75B38D62999361CD23C292 /* RunnerSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3193E87696207FEEEFD9B8C5 /* RunnerSettings.swift */; }; 14FDE1899EA56133607735BA /* aapt2-maven-metadata.xml in Resources */ = {isa = PBXBuildFile; fileRef = 7E6C6EC4702A6E611DFB62D7 /* aapt2-maven-metadata.xml */; }; 1626921543146C6FA82F81D6 /* DisplaySlotPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF6F4D274615104B6710401 /* DisplaySlotPool.swift */; }; 1B4D8CE7D085F87F5AAA3BD0 /* MandroidKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E3EBD02845AA73DAB1A6BEF8 /* MandroidKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 1B9150A0EB83F74AF90019E1 /* CaptureCertificateControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDD56CD517F9010B910606C /* CaptureCertificateControllerTests.swift */; }; 1CF4FDF17FF6A91548BFA798 /* DisplaySlot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 685C8671D0B85CE579655886 /* DisplaySlot.swift */; }; 201AD07F1E27F9E63ACB2867 /* sys-img2-3-google_apis_playstore.xml in Resources */ = {isa = PBXBuildFile; fileRef = 837A9A0CE90A9279D60ABFAA /* sys-img2-3-google_apis_playstore.xml */; }; 221603328B62AEFB3691A66E /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938C35D89238057FA9E32103 /* SetupView.swift */; }; + 229138E8FC14E24791295BA2 /* test-ca.pem in Resources */ = {isa = PBXBuildFile; fileRef = 0B3B3D58381D413C7713EE0F /* test-ca.pem */; }; 238AA060FA11039EAB0081ED /* dumpsys-display-3secondary.txt in Resources */ = {isa = PBXBuildFile; fileRef = B46C6E1A317C1DB9D1BEC272 /* dumpsys-display-3secondary.txt */; }; + 2455DF0F229E0E4D4A08514E /* WritableSystemInjectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C708788B125B9DCC6EEFDF1 /* WritableSystemInjectorTests.swift */; }; + 24E58D20408231246634DD46 /* KernelSUModuleInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2920378B7FD0EA3C0E7391FF /* KernelSUModuleInjector.swift */; }; 2591AC4E8C1926C76EC06C0D /* RunnerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C5C06B40C73CFB7A58BF53B /* RunnerState.swift */; }; 26596DADD30E895238D979E1 /* MandroidKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E3EBD02845AA73DAB1A6BEF8 /* MandroidKit.framework */; }; + 26DBB5FEBC2C27FB1EE4599F /* CaptureCertificateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C65C9684112751567F7BAB /* CaptureCertificateTests.swift */; }; 2783D70BFCB87F36B034DAFD /* ScrollChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13A02C3698E757F2C4C47096 /* ScrollChannel.swift */; }; 2956E519FF7E58F0CAB5E537 /* dumpsys-display-0only.txt in Resources */ = {isa = PBXBuildFile; fileRef = 3E22BE59D5B4E341E083891B /* dumpsys-display-0only.txt */; }; 2B01A437E721A3566272BD44 /* ADBClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C65349DBB2EA8395C63E55 /* ADBClient.swift */; }; + 2B6A9850417995B5BD8D079C /* CertificateAuthorityResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDDF5470531BD8A2E6F2274 /* CertificateAuthorityResolverTests.swift */; }; 2E8224C18133B6A58DDE0E24 /* proxy-agent.apk in Resources */ = {isa = PBXBuildFile; fileRef = 7989D6ECDD251AAA5E517B89 /* proxy-agent.apk */; }; 308932A4547F01DC338B9327 /* AppProxyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05E3D8D73C25821980720D7F /* AppProxyTests.swift */; }; + 30D26FBC50CC17A7C3F6449B /* CertificateAuthorityResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DDEE37F17CA9338C7026D8 /* CertificateAuthorityResolver.swift */; }; 32F0B027B070531C81B156BF /* KernelSUPatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DDF05B4DD2F5D50AEEB2D7 /* KernelSUPatcher.swift */; }; 32FB224EE27EAD34F6D50934 /* LauncherStubBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA57E57B95AE22134C1E9C8 /* LauncherStubBuilder.swift */; }; 33A7D28766027E7C46687D3F /* Subprocess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C26B1DC044AE793A2CA9408 /* Subprocess.swift */; }; + 33AD2256056A5BF4978035E9 /* GuestTrustStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BCADB00852120776BD29C93 /* GuestTrustStore.swift */; }; + 34994EF2CB2CAB688464106A /* WritableSystemInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F65FC59A8A992B5A2CDADC85 /* WritableSystemInjector.swift */; }; 34DCBE5B97CD020796A1351F /* AppWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55DA50087CE3FCBA56353A14 /* AppWindowController.swift */; }; 3527AAD84254AF8D34B7EEB6 /* FrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80179973E7A67D4C1D77F184 /* FrameView.swift */; }; 36C9FD8CA5994BB3814D6165 /* MainMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF930F7161F1DCE323E31A4B /* MainMenu.swift */; }; @@ -37,16 +47,20 @@ 3E2808F8E2220D9787390C22 /* AppMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F094A6E1BC089B2693B13E3E /* AppMigrationTests.swift */; }; 400DF40E5CA1F033D0C6024A /* GRPCFrameStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3112787BCD2CFEB6E6A41B16 /* GRPCFrameStream.swift */; }; 407A8753EE4BABD7F642049B /* Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6811683DF5318ABF587E2333 /* Fixtures.swift */; }; + 40B6888499C352BB67DCF117 /* CaptureCertificateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EB4E1A24D195175547FB1A1 /* CaptureCertificateController.swift */; }; 41BAA771990606C4529171BC /* ParkedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DE4A7E717C8676F19AF5A01 /* ParkedOverlayView.swift */; }; 446CFC09E215508235EF61BA /* APKBadging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22C4002AC149141CE2CCFEA /* APKBadging.swift */; }; 469D5D4A324C60D0952848A0 /* ADBClient+AppProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B6332F07ED146B775935E59 /* ADBClient+AppProxy.swift */; }; + 4AA858F7BA813146E13AF22B /* KernelSUModuleInjectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 916EAB7B84DDF6D661C54135 /* KernelSUModuleInjectorTests.swift */; }; 4CC46AA221A6EC77BCBB285C /* RunnerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16BF2F8305C02F1E8E553ECE /* RunnerCoordinator.swift */; }; 4F1A02EE0DB0CA2E4820E00D /* Frame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B34C727643E7E72B325A772 /* Frame.swift */; }; 4FF890186A809726BAAF7FC2 /* AppMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFDF475DAD0EB0CACFD2245 /* AppMigration.swift */; }; 512E94E7822481A2E4C2EC1F /* repository2-3.xml in Resources */ = {isa = PBXBuildFile; fileRef = D34956E2C8525B2923BD5DB9 /* repository2-3.xml */; }; + 51349E0315EC6F36D730BBF1 /* ProxyProbeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30E3FC3D61A0DE4B5AC00F83 /* ProxyProbeTests.swift */; }; 52624FADADB0BC7C839C61B4 /* LibraryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB8014AB35C738E335559BD /* LibraryWindowController.swift */; }; 5414AC4FF4CA8F8C8ECC2177 /* MandroidKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E3EBD02845AA73DAB1A6BEF8 /* MandroidKit.framework */; }; 544D132385DFD21F85F22A82 /* RepositoryManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0026E3FBD96D348C57055D /* RepositoryManifestTests.swift */; }; + 5707FDB3DE5C08E0A5A4BE60 /* CertificateInjectorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A9A296EAE6267FD1392BA9 /* CertificateInjectorFactory.swift */; }; 587B50F4C78DFE79DFC8BEF2 /* MediaVolumeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8606B63E97ACE543CFD3493 /* MediaVolumeTests.swift */; }; 5E9630B0793E9BB67881AF88 /* KeyMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A3873AFE57EB00977EA7D31 /* KeyMap.swift */; }; 5EBBC948AC1CF7D430323F8D /* EmulatorClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 624282BC5C68C313873805CF /* EmulatorClient.swift */; }; @@ -54,6 +68,7 @@ 6841C93EDB9A04236B33FA2C /* SDKBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0474DE5D80286AB4EFFC76CB /* SDKBootstrap.swift */; }; 6968875561F06E48324AB93C /* PortAllocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F844F828334E854BE651303 /* PortAllocatorTests.swift */; }; 69828A20E8FC79307F7B3A9E /* aapt2-badging-shadowsocks.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1112FF2A97D0DA989EEA2428 /* aapt2-badging-shadowsocks.txt */; }; + 6A55A88AA22ED8D958BDB316 /* CaptureCertificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15C9A959F17BF808285BB8D2 /* CaptureCertificate.swift */; }; 6DC9A901C87D8826C8D92CF7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A060D4CAD73DA56209C2447 /* AppDelegate.swift */; }; 6E4030E4FF2028250F1AD9B5 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 8B90226F41696CAD5B920D1F /* AppIcon.icon */; }; 6E665EC43F8884619B5C7EE3 /* SHA1.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC27965EAAF15BDD04A12AFD /* SHA1.swift */; }; @@ -64,18 +79,23 @@ 763AB37F01BD333C36956296 /* SDKPaths.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D06550EAA0A96BB8CB36BC /* SDKPaths.swift */; }; 7700A4B60AEF1DB5DC123672 /* dumpsys-display-1secondary.txt in Resources */ = {isa = PBXBuildFile; fileRef = C163D4A3B4515AECBAC00BD2 /* dumpsys-display-1secondary.txt */; }; 78B50525F4B22DD63646A684 /* KernelSUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76786C4CB12B3475CC4DE318 /* KernelSUTests.swift */; }; + 7ACA374D6FCB2E8002825EBB /* test-leaf.pem in Resources */ = {isa = PBXBuildFile; fileRef = 0B81BF8A161C0E64866F990D /* test-leaf.pem */; }; 7AE0BF2997499CBC7BD4B223 /* HTTPProxyEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA9A484A063F185D0AD4420 /* HTTPProxyEndpoint.swift */; }; + 7CE9984B42DFD6627C381290 /* CertificateChainChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07BE8129A4D87F0BB4A06BF5 /* CertificateChainChecker.swift */; }; 7E59DA6309CF5C31315A296E /* FrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80179973E7A67D4C1D77F184 /* FrameView.swift */; }; 820B8C8D33AA97A29C83B088 /* MandroidKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E3EBD02845AA73DAB1A6BEF8 /* MandroidKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 83D81BA5FBAEE3A77582AADD /* GuestSessionCapabilitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34C4358345961A9BBB25BEBC /* GuestSessionCapabilitiesTests.swift */; }; 84538782C4DCD2DB0AB6EB68 /* AVDConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A2D91E25EFC7A1E2703034 /* AVDConfig.swift */; }; 875091EB49C033602956E670 /* GuestSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFD778394E844C309B038E7 /* GuestSetup.swift */; }; 88D0F14A0F862C3C8E186765 /* SetupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41FB3548E919B9B9C44A7761 /* SetupWindowController.swift */; }; 8B721CD8AD02413A7D7A3993 /* EmulatorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC3E1C9F8EC238BF2D4E83F /* EmulatorSession.swift */; }; + 8BDAA2E37C8F07BEF6927774 /* CaptureCertificateStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1ECF00086E5ABDFAB13AD1 /* CaptureCertificateStore.swift */; }; 8D4ED6A726DA7E4EAAAE88B0 /* IconExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A577EDF958CD6BFC2A38D77 /* IconExtractor.swift */; }; 8F073A81288670F1E64D2A72 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDDC728FEC8C95305485F555 /* Log.swift */; }; 91BB969566A9EDE012EC6A97 /* ShellPipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BF262C36D42DC4E11E1FA2 /* ShellPipe.swift */; }; 95B4D81D941F24D9F6F6E6D3 /* DebugRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B597A920FF3617F9050114B /* DebugRecorder.swift */; }; 96176E04B147D3AC91814212 /* DumpsysDisplayParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11E38C038FB4B3588521DD2 /* DumpsysDisplayParser.swift */; }; + 96ED3CDE7D8F989E02D8DC4E /* CertificateChainCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86844D828E6BA4390CFE5E28 /* CertificateChainCheckerTests.swift */; }; 9B24B15DA44DF7AB251444C6 /* MediaVolume.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759F9FA0196823EB903E42BC /* MediaVolume.swift */; }; 9E377C462F430226BDAF01C2 /* guest-display.jar in Resources */ = {isa = PBXBuildFile; fileRef = 1B295493DF380A14E7678370 /* guest-display.jar */; }; A1101A652D2B25AB5185E29C /* URLSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB937303F6A23EDA3C41AFEE /* URLSchemeHandler.swift */; }; @@ -86,12 +106,14 @@ B2178D9D29C8F1F02D7F5DB5 /* PasteboardClipboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F68DF1547E8385DD5C111B /* PasteboardClipboard.swift */; }; B2DA180DC86E66C5C044B049 /* GRPCCore in Frameworks */ = {isa = PBXBuildFile; productRef = 17B27CC7ECC0679E3B7D1E78 /* GRPCCore */; }; B84370C1FA055B031EC1EA83 /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 573F637DED923589D2EA7A4D /* WindowManager.swift */; }; + BB9B45FBB3B4524D368C7318 /* GuestSessionCapabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0D7E5F3301BBAFCA3E6409 /* GuestSessionCapabilities.swift */; }; BE7BB2EA4A9B4702690989EC /* AppCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83297A94905C8AB9B23F9C3 /* AppCatalog.swift */; }; BF15DBC4080E020931218EA2 /* FrameViewInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D480828E2216330A480DAB98 /* FrameViewInputTests.swift */; }; C74B91BD02E590AA9175CEFB /* AppProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D696C8B2F08DE35734A372F0 /* AppProxyView.swift */; }; C799C337A2138CB133871FA2 /* GRPCProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D0A6D278C7A47F529FEE5404 /* GRPCProtobuf */; }; CC755A8A5F49632A6287CAD5 /* HostDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFDDC17B8230D60798FA257 /* HostDevicesTests.swift */; }; CDE305331380AF7CBD85F64C /* Unarchiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 452642B45534C755FF9E3CC4 /* Unarchiver.swift */; }; + CE52FE8CBC3AA03C0465C90D /* SystemImageSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2AC18630812D5298AA829B /* SystemImageSelectionTests.swift */; }; CF35185F02907A5A670B5C2A /* ScrollConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98537234E536D097055E8F0D /* ScrollConverter.swift */; }; D0AD9DA64E84B6D390110921 /* UITestMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6779B6FF861E9BD4662EA422 /* UITestMode.swift */; }; D11CCC1F6AC91DB80A92B9C9 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 259DC05229C3C583DD81182C /* LibraryView.swift */; }; @@ -105,16 +127,20 @@ E2F27000D2DF19DD22774D22 /* emulator_controller.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159C4A646B6236138BB76872 /* emulator_controller.grpc.swift */; }; E317A12345EA843B8B97F0C8 /* BootWaiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE16AFF83C05B8F4D2602E88 /* BootWaiter.swift */; }; E3238737CA8FBFF6982E86D1 /* GRPCNIOTransportHTTP2 in Frameworks */ = {isa = PBXBuildFile; productRef = 8E354424D0460E0A564243CB /* GRPCNIOTransportHTTP2 */; }; + E4EE32227883D05F404441B2 /* CertificateInjectorFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5243063650392A275F5A0B4 /* CertificateInjectorFactoryTests.swift */; }; E6C833D2D13CBF721F5B9C69 /* ClipboardSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3075A7A523AC3069D82546 /* ClipboardSync.swift */; }; E7BE7F27D9002E1CFCE3393C /* CoordinateMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65FC82E5A6022D0FD80D1A42 /* CoordinateMapper.swift */; }; E8D49C6D72B9D92CAF5DF2BD /* EmulatorConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DBE7D716FD2458F5DD3E580 /* EmulatorConnection.swift */; }; E8F64179DF7A44BC38D4662B /* Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6E418B7AE15E4E19E586FF /* Downloader.swift */; }; + EAAB37E1765B48CBAF116152 /* ProxyProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A60CCCF89E572B64C68E28 /* ProxyProbe.swift */; }; EC9013947910B9DC38E0C12A /* AVDStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E371D0580A2405FA5D9F861A /* AVDStore.swift */; }; EFBFEE050CED03AD2B4E3E41 /* DownloadMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BB7A4A6CDC3A601EB286D4 /* DownloadMirror.swift */; }; F04B857900FF2EA320B7B0EA /* GPUBackendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9C4268BD8813AED61B437D /* GPUBackendTests.swift */; }; F12B6081F2DF4A0290363E20 /* InputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1592A1AB2261451271FC320C /* InputTests.swift */; }; F6A7DC84B7247B1E506DA65D /* AAPT2Fetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFC50724898B32D7CC77A08 /* AAPT2Fetcher.swift */; }; + F9CB7AC9C5BBC967695B0CAC /* CertificateInjecting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75462D3E91DCA917600F9034 /* CertificateInjecting.swift */; }; F9CE9BA3584809592025885E /* TaskListParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F57E157A54D12371B3287E5 /* TaskListParser.swift */; }; + FC5051EC40D3ABE4265B3048 /* other-ca.pem in Resources */ = {isa = PBXBuildFile; fileRef = D318808C880BB008E4219BAB /* other-ca.pem */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -162,8 +188,11 @@ /* Begin PBXFileReference section */ 0474DE5D80286AB4EFFC76CB /* SDKBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKBootstrap.swift; sourceTree = ""; }; 05E3D8D73C25821980720D7F /* AppProxyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppProxyTests.swift; sourceTree = ""; }; + 07BE8129A4D87F0BB4A06BF5 /* CertificateChainChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateChainChecker.swift; sourceTree = ""; }; 08D361DE1BDD3FCEC2E336DE /* HostStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostStyle.swift; sourceTree = ""; }; 08F68DF1547E8385DD5C111B /* PasteboardClipboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardClipboard.swift; sourceTree = ""; }; + 0B3B3D58381D413C7713EE0F /* test-ca.pem */ = {isa = PBXFileReference; path = "test-ca.pem"; sourceTree = ""; }; + 0B81BF8A161C0E64866F990D /* test-leaf.pem */ = {isa = PBXFileReference; path = "test-leaf.pem"; sourceTree = ""; }; 0CA57E57B95AE22134C1E9C8 /* LauncherStubBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LauncherStubBuilder.swift; sourceTree = ""; }; 0F844F828334E854BE651303 /* PortAllocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortAllocatorTests.swift; sourceTree = ""; }; 0F939B33E5C022C395681A59 /* EmulatorLaunchOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmulatorLaunchOptions.swift; sourceTree = ""; }; @@ -171,6 +200,7 @@ 13A02C3698E757F2C4C47096 /* ScrollChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollChannel.swift; sourceTree = ""; }; 1592A1AB2261451271FC320C /* InputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputTests.swift; sourceTree = ""; }; 159C4A646B6236138BB76872 /* emulator_controller.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = emulator_controller.grpc.swift; sourceTree = ""; }; + 15C9A959F17BF808285BB8D2 /* CaptureCertificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureCertificate.swift; sourceTree = ""; }; 16BF2F8305C02F1E8E553ECE /* RunnerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerCoordinator.swift; sourceTree = ""; }; 1B295493DF380A14E7678370 /* guest-display.jar */ = {isa = PBXFileReference; lastKnownFileType = archive.jar; path = "guest-display.jar"; sourceTree = ""; }; 1E3075A7A523AC3069D82546 /* ClipboardSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardSync.swift; sourceTree = ""; }; @@ -178,11 +208,17 @@ 1F57E157A54D12371B3287E5 /* TaskListParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListParser.swift; sourceTree = ""; }; 22DD162AE710D7293ABAE6DA /* RepositoryManifest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryManifest.swift; sourceTree = ""; }; 259DC05229C3C583DD81182C /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; + 2920378B7FD0EA3C0E7391FF /* KernelSUModuleInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KernelSUModuleInjector.swift; sourceTree = ""; }; 2B6332F07ED146B775935E59 /* ADBClient+AppProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ADBClient+AppProxy.swift"; sourceTree = ""; }; + 2BCADB00852120776BD29C93 /* GuestTrustStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestTrustStore.swift; sourceTree = ""; }; 2CFC50724898B32D7CC77A08 /* AAPT2Fetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AAPT2Fetcher.swift; sourceTree = ""; }; + 2EB4E1A24D195175547FB1A1 /* CaptureCertificateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureCertificateController.swift; sourceTree = ""; }; 2F3481CEAFB41E623B50A605 /* AuditRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditRegressionTests.swift; sourceTree = ""; }; + 30E3FC3D61A0DE4B5AC00F83 /* ProxyProbeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyProbeTests.swift; sourceTree = ""; }; 3112787BCD2CFEB6E6A41B16 /* GRPCFrameStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GRPCFrameStream.swift; sourceTree = ""; }; 3193E87696207FEEEFD9B8C5 /* RunnerSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerSettings.swift; sourceTree = ""; }; + 33DDEE37F17CA9338C7026D8 /* CertificateAuthorityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateAuthorityResolver.swift; sourceTree = ""; }; + 34C4358345961A9BBB25BEBC /* GuestSessionCapabilitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestSessionCapabilitiesTests.swift; sourceTree = ""; }; 39DDF05B4DD2F5D50AEEB2D7 /* KernelSUPatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KernelSUPatcher.swift; sourceTree = ""; }; 3B34C727643E7E72B325A772 /* Frame.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Frame.swift; sourceTree = ""; }; 3C5C06B40C73CFB7A58BF53B /* RunnerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerState.swift; sourceTree = ""; }; @@ -195,20 +231,26 @@ 4BB8014AB35C738E335559BD /* LibraryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryWindowController.swift; sourceTree = ""; }; 4D6E418B7AE15E4E19E586FF /* Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloader.swift; sourceTree = ""; }; 4FFDDC17B8230D60798FA257 /* HostDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostDevicesTests.swift; sourceTree = ""; }; + 50A9A296EAE6267FD1392BA9 /* CertificateInjectorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateInjectorFactory.swift; sourceTree = ""; }; 55DA50087CE3FCBA56353A14 /* AppWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppWindowController.swift; sourceTree = ""; }; 573F637DED923589D2EA7A4D /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; + 5B1ECF00086E5ABDFAB13AD1 /* CaptureCertificateStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureCertificateStore.swift; sourceTree = ""; }; 5B597A920FF3617F9050114B /* DebugRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugRecorder.swift; sourceTree = ""; }; 5D33770E780457C7F1F097F5 /* GPUBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GPUBackend.swift; sourceTree = ""; }; 624282BC5C68C313873805CF /* EmulatorClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmulatorClient.swift; sourceTree = ""; }; 65FC82E5A6022D0FD80D1A42 /* CoordinateMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinateMapper.swift; sourceTree = ""; }; + 676DB9C812A2C2AFFB56962F /* GuestTrustStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestTrustStoreTests.swift; sourceTree = ""; }; 6779B6FF861E9BD4662EA422 /* UITestMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestMode.swift; sourceTree = ""; }; 6811683DF5318ABF587E2333 /* Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fixtures.swift; sourceTree = ""; }; 685C8671D0B85CE579655886 /* DisplaySlot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySlot.swift; sourceTree = ""; }; 6A3873AFE57EB00977EA7D31 /* KeyMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyMap.swift; sourceTree = ""; }; 6DE4A7E717C8676F19AF5A01 /* ParkedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkedOverlayView.swift; sourceTree = ""; }; + 6EDD56CD517F9010B910606C /* CaptureCertificateControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureCertificateControllerTests.swift; sourceTree = ""; }; + 75462D3E91DCA917600F9034 /* CertificateInjecting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateInjecting.swift; sourceTree = ""; }; 759F9FA0196823EB903E42BC /* MediaVolume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaVolume.swift; sourceTree = ""; }; 76786C4CB12B3475CC4DE318 /* KernelSUTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KernelSUTests.swift; sourceTree = ""; }; 782AB065419FE991C79921CE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 78C65C9684112751567F7BAB /* CaptureCertificateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureCertificateTests.swift; sourceTree = ""; }; 7989D6ECDD251AAA5E517B89 /* proxy-agent.apk */ = {isa = PBXFileReference; path = "proxy-agent.apk"; sourceTree = ""; }; 7A060D4CAD73DA56209C2447 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AABAAC0B4AC9941A2D95201 /* MandroidKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MandroidKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -217,9 +259,13 @@ 80F2346B9A7C3DA473E35B9C /* Mandroid.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Mandroid.entitlements; sourceTree = ""; }; 837A9A0CE90A9279D60ABFAA /* sys-img2-3-google_apis_playstore.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "sys-img2-3-google_apis_playstore.xml"; sourceTree = ""; }; 85BB7A4A6CDC3A601EB286D4 /* DownloadMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadMirror.swift; sourceTree = ""; }; + 86844D828E6BA4390CFE5E28 /* CertificateChainCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateChainCheckerTests.swift; sourceTree = ""; }; 8A577EDF958CD6BFC2A38D77 /* IconExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconExtractor.swift; sourceTree = ""; }; 8B90226F41696CAD5B920D1F /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; + 8C0D7E5F3301BBAFCA3E6409 /* GuestSessionCapabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestSessionCapabilities.swift; sourceTree = ""; }; 8C26B1DC044AE793A2CA9408 /* Subprocess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Subprocess.swift; sourceTree = ""; }; + 8C708788B125B9DCC6EEFDF1 /* WritableSystemInjectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritableSystemInjectorTests.swift; sourceTree = ""; }; + 916EAB7B84DDF6D661C54135 /* KernelSUModuleInjectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KernelSUModuleInjectorTests.swift; sourceTree = ""; }; 92A128C67BD4F9AA42264303 /* DeviceProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProfileTests.swift; sourceTree = ""; }; 938C35D89238057FA9E32103 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; 95AABDC6915A7C99384D3459 /* InputRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputRouter.swift; sourceTree = ""; }; @@ -244,11 +290,15 @@ C8D06550EAA0A96BB8CB36BC /* SDKPaths.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKPaths.swift; sourceTree = ""; }; C97FCFF669F1D99C5B174E18 /* MandroidKitError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MandroidKitError.swift; sourceTree = ""; }; C9A2D91E25EFC7A1E2703034 /* AVDConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVDConfig.swift; sourceTree = ""; }; + CCDDF5470531BD8A2E6F2274 /* CertificateAuthorityResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateAuthorityResolverTests.swift; sourceTree = ""; }; CCFDF475DAD0EB0CACFD2245 /* AppMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigration.swift; sourceTree = ""; }; CDDC728FEC8C95305485F555 /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; D11E38C038FB4B3588521DD2 /* DumpsysDisplayParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DumpsysDisplayParser.swift; sourceTree = ""; }; + D318808C880BB008E4219BAB /* other-ca.pem */ = {isa = PBXFileReference; path = "other-ca.pem"; sourceTree = ""; }; D34956E2C8525B2923BD5DB9 /* repository2-3.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "repository2-3.xml"; sourceTree = ""; }; D480828E2216330A480DAB98 /* FrameViewInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameViewInputTests.swift; sourceTree = ""; }; + D4A60CCCF89E572B64C68E28 /* ProxyProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyProbe.swift; sourceTree = ""; }; + D5243063650392A275F5A0B4 /* CertificateInjectorFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateInjectorFactoryTests.swift; sourceTree = ""; }; D696C8B2F08DE35734A372F0 /* AppProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppProxyView.swift; sourceTree = ""; }; D8606B63E97ACE543CFD3493 /* MediaVolumeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaVolumeTests.swift; sourceTree = ""; }; D9850A27EBA910D608B21E5D /* PortAllocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortAllocator.swift; sourceTree = ""; }; @@ -260,7 +310,9 @@ EC27965EAAF15BDD04A12AFD /* SHA1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SHA1.swift; sourceTree = ""; }; EE16AFF83C05B8F4D2602E88 /* BootWaiter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BootWaiter.swift; sourceTree = ""; }; F094A6E1BC089B2693B13E3E /* AppMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMigrationTests.swift; sourceTree = ""; }; + F65FC59A8A992B5A2CDADC85 /* WritableSystemInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WritableSystemInjector.swift; sourceTree = ""; }; FDF6F4D274615104B6710401 /* DisplaySlotPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySlotPool.swift; sourceTree = ""; }; + FE2AC18630812D5298AA829B /* SystemImageSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemImageSelectionTests.swift; sourceTree = ""; }; FF930F7161F1DCE323E31A4B /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = ""; }; FFFD778394E844C309B038E7 /* GuestSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuestSetup.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -311,6 +363,7 @@ 0F939B33E5C022C395681A59 /* EmulatorLaunchOptions.swift */, E7BED9150E46ED563A22EE48 /* EmulatorProcess.swift */, 5D33770E780457C7F1F097F5 /* GPUBackend.swift */, + 8C0D7E5F3301BBAFCA3E6409 /* GuestSessionCapabilities.swift */, FFFD778394E844C309B038E7 /* GuestSetup.swift */, D9850A27EBA910D608B21E5D /* PortAllocator.swift */, ); @@ -325,8 +378,11 @@ 3E22BE59D5B4E341E083891B /* dumpsys-display-0only.txt */, C163D4A3B4515AECBAC00BD2 /* dumpsys-display-1secondary.txt */, B46C6E1A317C1DB9D1BEC272 /* dumpsys-display-3secondary.txt */, + D318808C880BB008E4219BAB /* other-ca.pem */, D34956E2C8525B2923BD5DB9 /* repository2-3.xml */, 837A9A0CE90A9279D60ABFAA /* sys-img2-3-google_apis_playstore.xml */, + 0B3B3D58381D413C7713EE0F /* test-ca.pem */, + 0B81BF8A161C0E64866F990D /* test-leaf.pem */, ); path = Fixtures; sourceTree = ""; @@ -513,6 +569,7 @@ children = ( 54F294439BF0F86A31A06178 /* ADB */, 7B2374B4E9CFAF705A1591B8 /* AVD */, + D3C43C2BB3817C070E6D3D3E /* Capture */, 9EFCD8F6C6C0362513C81793 /* Catalog */, B17D2881FDD5A8D3B1802AA6 /* Client */, B23B7CCB109841EE595FDD25 /* Clipboard */, @@ -586,23 +643,52 @@ F094A6E1BC089B2693B13E3E /* AppMigrationTests.swift */, 05E3D8D73C25821980720D7F /* AppProxyTests.swift */, 2F3481CEAFB41E623B50A605 /* AuditRegressionTests.swift */, + 6EDD56CD517F9010B910606C /* CaptureCertificateControllerTests.swift */, + 78C65C9684112751567F7BAB /* CaptureCertificateTests.swift */, AE44AA57BFC4862E28A97484 /* CatalogTests.swift */, + CCDDF5470531BD8A2E6F2274 /* CertificateAuthorityResolverTests.swift */, + 86844D828E6BA4390CFE5E28 /* CertificateChainCheckerTests.swift */, + D5243063650392A275F5A0B4 /* CertificateInjectorFactoryTests.swift */, 92A128C67BD4F9AA42264303 /* DeviceProfileTests.swift */, 6811683DF5318ABF587E2333 /* Fixtures.swift */, D480828E2216330A480DAB98 /* FrameViewInputTests.swift */, AD9C4268BD8813AED61B437D /* GPUBackendTests.swift */, + 34C4358345961A9BBB25BEBC /* GuestSessionCapabilitiesTests.swift */, + 676DB9C812A2C2AFFB56962F /* GuestTrustStoreTests.swift */, 4FFDDC17B8230D60798FA257 /* HostDevicesTests.swift */, 1592A1AB2261451271FC320C /* InputTests.swift */, + 916EAB7B84DDF6D661C54135 /* KernelSUModuleInjectorTests.swift */, 76786C4CB12B3475CC4DE318 /* KernelSUTests.swift */, D8606B63E97ACE543CFD3493 /* MediaVolumeTests.swift */, 96276D7B0CFD82A66CC11112 /* ParserTests.swift */, 0F844F828334E854BE651303 /* PortAllocatorTests.swift */, + 30E3FC3D61A0DE4B5AC00F83 /* ProxyProbeTests.swift */, AD0026E3FBD96D348C57055D /* RepositoryManifestTests.swift */, + FE2AC18630812D5298AA829B /* SystemImageSelectionTests.swift */, + 8C708788B125B9DCC6EEFDF1 /* WritableSystemInjectorTests.swift */, 492A92392BD829D1EEAE3EE7 /* Fixtures */, ); path = MandroidKitTests; sourceTree = ""; }; + D3C43C2BB3817C070E6D3D3E /* Capture */ = { + isa = PBXGroup; + children = ( + 15C9A959F17BF808285BB8D2 /* CaptureCertificate.swift */, + 2EB4E1A24D195175547FB1A1 /* CaptureCertificateController.swift */, + 5B1ECF00086E5ABDFAB13AD1 /* CaptureCertificateStore.swift */, + 33DDEE37F17CA9338C7026D8 /* CertificateAuthorityResolver.swift */, + 07BE8129A4D87F0BB4A06BF5 /* CertificateChainChecker.swift */, + 75462D3E91DCA917600F9034 /* CertificateInjecting.swift */, + 50A9A296EAE6267FD1392BA9 /* CertificateInjectorFactory.swift */, + 2BCADB00852120776BD29C93 /* GuestTrustStore.swift */, + 2920378B7FD0EA3C0E7391FF /* KernelSUModuleInjector.swift */, + D4A60CCCF89E572B64C68E28 /* ProxyProbe.swift */, + F65FC59A8A992B5A2CDADC85 /* WritableSystemInjector.swift */, + ); + path = Capture; + sourceTree = ""; + }; D7AE80EE0661981B1AEBEA1F /* Generated */ = { isa = PBXGroup; children = ( @@ -737,8 +823,8 @@ projectDirPath = ""; projectRoot = ""; targets = ( - 7565C553CAA059ED4D807908 /* Mandroid */, 7116F660B4E2AD3AE675827A /* MandroidKit */, + 7565C553CAA059ED4D807908 /* Mandroid */, E013D7B55A81433B96B45E16 /* MandroidKitTests */, ); }; @@ -754,8 +840,11 @@ 2956E519FF7E58F0CAB5E537 /* dumpsys-display-0only.txt in Resources */, 7700A4B60AEF1DB5DC123672 /* dumpsys-display-1secondary.txt in Resources */, 238AA060FA11039EAB0081ED /* dumpsys-display-3secondary.txt in Resources */, + FC5051EC40D3ABE4265B3048 /* other-ca.pem in Resources */, 512E94E7822481A2E4C2EC1F /* repository2-3.xml in Resources */, 201AD07F1E27F9E63ACB2867 /* sys-img2-3-google_apis_playstore.xml in Resources */, + 229138E8FC14E24791295BA2 /* test-ca.pem in Resources */, + 7ACA374D6FCB2E8002825EBB /* test-leaf.pem in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -814,20 +903,31 @@ 3E2808F8E2220D9787390C22 /* AppMigrationTests.swift in Sources */, 308932A4547F01DC338B9327 /* AppProxyTests.swift in Sources */, 00EBD9C62DC4790FF40C7C6B /* AuditRegressionTests.swift in Sources */, + 1B9150A0EB83F74AF90019E1 /* CaptureCertificateControllerTests.swift in Sources */, + 26DBB5FEBC2C27FB1EE4599F /* CaptureCertificateTests.swift in Sources */, 042A528ECE7E1A97BEF0C932 /* CatalogTests.swift in Sources */, + 2B6A9850417995B5BD8D079C /* CertificateAuthorityResolverTests.swift in Sources */, + 96ED3CDE7D8F989E02D8DC4E /* CertificateChainCheckerTests.swift in Sources */, + E4EE32227883D05F404441B2 /* CertificateInjectorFactoryTests.swift in Sources */, ABA069897E3CF4B5AAF49F2E /* DeviceProfileTests.swift in Sources */, 407A8753EE4BABD7F642049B /* Fixtures.swift in Sources */, 7E59DA6309CF5C31315A296E /* FrameView.swift in Sources */, BF15DBC4080E020931218EA2 /* FrameViewInputTests.swift in Sources */, F04B857900FF2EA320B7B0EA /* GPUBackendTests.swift in Sources */, + 83D81BA5FBAEE3A77582AADD /* GuestSessionCapabilitiesTests.swift in Sources */, + 0C9DE5409AEF3F62E715D872 /* GuestTrustStoreTests.swift in Sources */, CC755A8A5F49632A6287CAD5 /* HostDevicesTests.swift in Sources */, F12B6081F2DF4A0290363E20 /* InputTests.swift in Sources */, + 4AA858F7BA813146E13AF22B /* KernelSUModuleInjectorTests.swift in Sources */, 78B50525F4B22DD63646A684 /* KernelSUTests.swift in Sources */, D2F4038A521D72D1500DEBA4 /* LauncherStubBuilder.swift in Sources */, 587B50F4C78DFE79DFC8BEF2 /* MediaVolumeTests.swift in Sources */, A2429DDC765F9DCA5078F7BD /* ParserTests.swift in Sources */, 6968875561F06E48324AB93C /* PortAllocatorTests.swift in Sources */, + 51349E0315EC6F36D730BBF1 /* ProxyProbeTests.swift in Sources */, 544D132385DFD21F85F22A82 /* RepositoryManifestTests.swift in Sources */, + CE52FE8CBC3AA03C0465C90D /* SystemImageSelectionTests.swift in Sources */, + 2455DF0F229E0E4D4A08514E /* WritableSystemInjectorTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -844,6 +944,13 @@ BE7BB2EA4A9B4702690989EC /* AppCatalog.swift in Sources */, 4FF890186A809726BAAF7FC2 /* AppMigration.swift in Sources */, E317A12345EA843B8B97F0C8 /* BootWaiter.swift in Sources */, + 6A55A88AA22ED8D958BDB316 /* CaptureCertificate.swift in Sources */, + 40B6888499C352BB67DCF117 /* CaptureCertificateController.swift in Sources */, + 8BDAA2E37C8F07BEF6927774 /* CaptureCertificateStore.swift in Sources */, + 30D26FBC50CC17A7C3F6449B /* CertificateAuthorityResolver.swift in Sources */, + 7CE9984B42DFD6627C381290 /* CertificateChainChecker.swift in Sources */, + F9CB7AC9C5BBC967695B0CAC /* CertificateInjecting.swift in Sources */, + 5707FDB3DE5C08E0A5A4BE60 /* CertificateInjectorFactory.swift in Sources */, E6C833D2D13CBF721F5B9C69 /* ClipboardSync.swift in Sources */, E7BE7F27D9002E1CFCE3393C /* CoordinateMapper.swift in Sources */, 05874F1DF6A7CD0C4834B90C /* DeviceProfile.swift in Sources */, @@ -860,11 +967,14 @@ 4F1A02EE0DB0CA2E4820E00D /* Frame.swift in Sources */, DD46327E5999D21122E576B3 /* GPUBackend.swift in Sources */, 400DF40E5CA1F033D0C6024A /* GRPCFrameStream.swift in Sources */, + BB9B45FBB3B4524D368C7318 /* GuestSessionCapabilities.swift in Sources */, 875091EB49C033602956E670 /* GuestSetup.swift in Sources */, + 33AD2256056A5BF4978035E9 /* GuestTrustStore.swift in Sources */, 7AE0BF2997499CBC7BD4B223 /* HTTPProxyEndpoint.swift in Sources */, 8D4ED6A726DA7E4EAAAE88B0 /* IconExtractor.swift in Sources */, 74A93C1F77047983F9AF98C6 /* InputChannel.swift in Sources */, D31CB0D9A9380FA508B0B53C /* InputRouter.swift in Sources */, + 24E58D20408231246634DD46 /* KernelSUModuleInjector.swift in Sources */, 32F0B027B070531C81B156BF /* KernelSUPatcher.swift in Sources */, 5E9630B0793E9BB67881AF88 /* KeyMap.swift in Sources */, 8F073A81288670F1E64D2A72 /* Log.swift in Sources */, @@ -872,6 +982,7 @@ 9B24B15DA44DF7AB251444C6 /* MediaVolume.swift in Sources */, 713C2AB7969825E509E648CE /* PackageListParser.swift in Sources */, B1A174E26EE31BFC25E166CE /* PortAllocator.swift in Sources */, + EAAB37E1765B48CBAF116152 /* ProxyProbe.swift in Sources */, E065A1D2DBB653C32951E245 /* RepositoryManifest.swift in Sources */, 4CC46AA221A6EC77BCBB285C /* RunnerCoordinator.swift in Sources */, 0E75B38D62999361CD23C292 /* RunnerSettings.swift in Sources */, @@ -885,6 +996,7 @@ 33A7D28766027E7C46687D3F /* Subprocess.swift in Sources */, F9CE9BA3584809592025885E /* TaskListParser.swift in Sources */, CDE305331380AF7CBD85F64C /* Unarchiver.swift in Sources */, + 34994EF2CB2CAB688464106A /* WritableSystemInjector.swift in Sources */, E2F27000D2DF19DD22774D22 /* emulator_controller.grpc.swift in Sources */, D4BC40D0D59F484F16264828 /* emulator_controller.pb.swift in Sources */, ); diff --git a/Mandroid/Settings/SettingsView.swift b/Mandroid/Settings/SettingsView.swift index d8ac9d7..7b49033 100644 --- a/Mandroid/Settings/SettingsView.swift +++ b/Mandroid/Settings/SettingsView.swift @@ -1,5 +1,6 @@ import MandroidKit import SwiftUI +import UniformTypeIdentifiers struct SettingsView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -11,11 +12,26 @@ struct SettingsView: View { @State private var volumeReady = false @State private var applyingVolume = false @State private var volumeError: String? + @State private var pasteText = "" + @State private var proxyText = "" + @State private var captureState: CaptureControllerState = .idle + @State private var captureVerdict: ChainVerdict? + @State private var captureError: String? + @State private var captureBusy = false + @State private var imageDownload: ImageDownload = .idle + @State private var imageNotice: String? private var needsRestart: Bool { if let session = coordinator.session { if (session.options.kernelSURamdisk != nil) != settings.kernelSUEnabled { return true } + // Installing a capture certificate needs a session booted from a + // shell-rooted patch, which is the choice recorded on its ramdisk. + // Only a KernelSU patch can carry that choice, so a restart is only + // pending while KernelSU is on: with it off, no patch is prepared + // and no restart can satisfy the request. + if settings.kernelSUEnabled, + KernelSUPatcher.allowsShell(ramdisk: session.options.kernelSURamdisk) != settings.captureCertificateEnabled { return true } let display = settings.deviceDisplay if session.deviceWidth != display.widthPixels || session.deviceHeight != display.heightPixels || session.deviceDpi != display.density { return true } @@ -85,6 +101,45 @@ struct SettingsView: View { Link("KernelSU project and license", destination: URL(string: "https://github.com/tiann/KernelSU")!) .font(.caption) } + Section("Capture certificates") { + Text("Adds a debugging proxy's root CA to the virtual device's system trust store so apps trust captured HTTPS traffic. Installing needs a rooted session: turn this on and restart with KernelSU enabled to prepare one, or boot a userdebug image with a writable system partition. Apps that pin their own certificate still fail.") + .font(.caption).foregroundStyle(.secondary) + Toggle("Install a capture proxy certificate", isOn: $settings.captureCertificateEnabled) + // A session booted from a userdebug image with a writable + // system partition is rooted without KernelSU, so the warning + // only applies while neither shape is chosen. + if settings.captureCertificateEnabled && !settings.kernelSUEnabled && !settings.writableSystemEnabled { + HostNotice(message: "This needs a rooted session, and KernelSU is off. Turn on \"Boot with KernelSU\" above, then restart the emulator.") + } + HStack { + Button("Choose Certificate…") { pickCertificate() } + .disabled(captureControlsDisabled) + Spacer() + } + HStack { + TextField("Paste PEM certificate", text: $pasteText, axis: .vertical) + .lineLimit(2...5) + .textFieldStyle(.roundedBorder) + Button("Use Pasted Certificate") { usePastedCertificate() } + .disabled(captureControlsDisabled || pasteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + HStack { + TextField("Proxy host:port", text: $proxyText) + .textFieldStyle(.roundedBorder) + Button("Get from Proxy…") { fetchFromProxy() } + .disabled(captureControlsDisabled || proxyEndpoint == nil) + } + if !captureAvailable { + Text("Available while the emulator is running.").font(.caption).foregroundStyle(.secondary) + } + captureStatus + HStack { + if captureBusy { ProgressView().controlSize(.small) } + Button("Remove Certificate") { removeCertificate() } + .disabled(captureControlsDisabled || !captureInstalled) + Spacer() + } + } Section("Audio") { HStack { Slider(value: $volume, in: 0...100, step: 1, label: { Text("Media volume") }, onEditingChanged: { editing in @@ -128,8 +183,26 @@ struct SettingsView: View { .font(.caption).foregroundStyle(.secondary) } Section("Files") { - LabeledContent("System image") { - Text(coordinator.bootstrap.installedSystemImage()?.packagePath ?? "none").textSelection(.enabled) + LabeledContent("Installed image") { + Text(coordinator.bootstrap.installedSystemImage(tag: settings.systemImageTag)?.packagePath ?? "none").textSelection(.enabled) + } + Picker("Boot with", selection: $settings.systemImageTag) { + ForEach(imageTagChoices, id: \.self) { tag in + Text(installedImageTags.contains(tag) ? tag : "\(tag) (not installed)").tag(tag) + } + } + if !installedImageTags.contains(settings.systemImageTag) { + Button("Download \(settings.systemImageTag) Image") { downloadSystemImage() } + .disabled(imageDownloadActive || runnerIsBusy) + Text("A userdebug image: unlike the Play image, it accepts a writable system partition. Restart the emulator to boot it.") + .font(.caption).foregroundStyle(.secondary) + } + Toggle("Boot with a writable system partition", isOn: $settings.writableSystemEnabled) + Text("Boots the selected image with a writable system partition, which installing a capture certificate straight into the system trust store needs. Meaningful on a userdebug image; a Play image ignores it.") + .font(.caption).foregroundStyle(.secondary) + imageDownloadStatus + if let imageNotice { + Text(imageNotice).font(.caption).foregroundStyle(.secondary) } HStack { Button("Show Logs") { NSWorkspace.shared.open(coordinator.paths.logs) } @@ -142,6 +215,15 @@ struct SettingsView: View { .frame(minWidth: 480, idealWidth: 560, minHeight: 420, idealHeight: 720) .animation(HostStyle.motion(reduceMotion: reduceMotion), value: needsRestart) .animation(HostStyle.motion(reduceMotion: reduceMotion), value: volumeError != nil) + .animation(HostStyle.motion(reduceMotion: reduceMotion), value: captureVerdict) + .animation(HostStyle.motion(reduceMotion: reduceMotion), value: captureError != nil) + .animation(HostStyle.motion(reduceMotion: reduceMotion), value: captureBusy) + .task { + // The record seeds the state, and it is the only account of what + // the guest holds: it is read without asking the session what it + // can do, because it has to be shown either way. + captureState = CaptureControllerState(store: CaptureCertificateStore(root: coordinator.paths.root)) + } .task(id: coordinator.state.isReady) { volumeReady = false guard coordinator.state.isReady, let adb = coordinator.session?.adb else { return } @@ -181,6 +263,356 @@ struct SettingsView: View { windows.closeAll() Task { await coordinator.restart(coldBoot: cold) } } + + // MARK: System images + + /// One system image download, as the Files section shows it. + private enum ImageDownload: Equatable { + case idle + case fetching(String) + case downloading(DownloadProgress) + case unpacking(String) + case failed(String) + } + + /// Tags of the installed images, newest API level only. + private var installedImageTags: [String] { + coordinator.bootstrap.installedSystemImages().map(\.tag) + } + + /// Installed tags plus the two the picker must offer anyway: the selection + /// itself (which may be waiting to be downloaded) and the userdebug image. + private var imageTagChoices: [String] { + var tags = installedImageTags + for tag in [settings.systemImageTag, SDKBootstrap.userdebugTag] where !tags.contains(tag) { tags.append(tag) } + return tags + } + + private var imageDownloadActive: Bool { + switch imageDownload { + case .fetching, .downloading, .unpacking: return true + case .idle, .failed: return false + } + } + + /// Downloading while the runner prepares its own components would race for + /// the same staging directory. + private var runnerIsBusy: Bool { + switch coordinator.state { + case .checking, .settingUp, .booting, .shuttingDown: return true + case .idle, .needsSetup, .ready, .failed: return false + } + } + + @ViewBuilder private var imageDownloadStatus: some View { + switch imageDownload { + case .idle: + EmptyView() + case .fetching(let tag): + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Reading the \(tag) manifest…").font(.caption).foregroundStyle(.secondary) + } + case .downloading(let progress): + VStack(alignment: .leading, spacing: 4) { + if let fraction = progress.fraction { ProgressView(value: fraction) } + else { ProgressView().controlSize(.small) } + Text(imageProgressLabel(progress)).font(.caption).foregroundStyle(.secondary).monospacedDigit() + } + case .unpacking(let name): + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Unpacking \(name)…").font(.caption).foregroundStyle(.secondary) + } + case .failed(let message): + HostNotice(message: message) + } + } + + private func imageProgressLabel(_ progress: DownloadProgress) -> String { + let received = ByteCountFormatter.string(fromByteCount: progress.received, countStyle: .file) + guard let total = progress.total else { return "Downloaded \(received)" } + return "Downloaded \(received) of \(ByteCountFormatter.string(fromByteCount: total, countStyle: .file))" + } + + /// Downloads the selected system image. The tag's own manifest supplies the + /// archive and its size, which the confirmation states before anything is + /// fetched; only this button starts a download. + private func downloadSystemImage() { + let tag = settings.systemImageTag + guard !imageDownloadActive, !runnerIsBusy else { return } + imageNotice = nil + imageDownload = .fetching(tag) + Task { + let bootstrap = coordinator.bootstrap + do { + await bootstrap.setMirrors(settings.mirrors) + let plan = try await bootstrap.makePlan(tag: tag) + guard let image = plan.components.first(where: { $0.package.path.hasPrefix("system-images;") }) else { + imageDownload = .idle + imageNotice = "The \(tag) system image is already installed." + return + } + imageDownload = .idle + guard confirmImageDownload(image, in: plan) else { return } + try await bootstrap.run(plan) { phase in + Task { @MainActor in + switch phase { + case .fetchingManifests: imageDownload = .fetching(tag) + case .downloading(_, let progress): imageDownload = .downloading(progress) + case .extracting(let name): imageDownload = .unpacking(name) + case .finished: imageDownload = .idle + } + } + } + imageDownload = .idle + imageNotice = "\(image.displayName) is installed. Restart the emulator to boot it." + } catch { + imageDownload = .failed(error.localizedDescription) + } + } + } + + /// States the archive the manifest points at, and its size, before + /// anything is fetched. + private func confirmImageDownload(_ image: SDKComponent, in plan: BootstrapPlan) -> Bool { + let bytes = ByteCountFormatter.string(fromByteCount: image.sizeBytes, countStyle: .file) + var lines = ["\(image.displayName): \(bytes) from \(plan.mirror.host)."] + let others = plan.components.filter { $0.id != image.id } + if !others.isEmpty { + let otherBytes = ByteCountFormatter.string(fromByteCount: plan.totalBytes - image.sizeBytes, countStyle: .file) + lines.append("Also installs \(others.map(\.displayName).joined(separator: ", ")) (\(otherBytes)).") + } + lines.append("It is installed into the Mandroid data folder and used the next time the emulator starts.") + let alert = NSAlert() + alert.messageText = "Download the \(image.package.tagID ?? "system") system image?" + alert.informativeText = lines.joined(separator: "\n") + alert.addButton(withTitle: "Download") + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + + // MARK: Capture certificates + + private var captureAvailable: Bool { coordinator.session?.adb != nil } + + private var captureControlsDisabled: Bool { !captureAvailable || captureBusy } + + private var captureInstalled: Bool { + if case .installed = captureState { return true } + return false + } + + /// The typed proxy address, or nil while the field is empty or incomplete. + private var proxyEndpoint: HTTPProxyEndpoint? { + let text = proxyText.trimmingCharacters(in: .whitespaces) + guard let colon = text.lastIndex(of: ":"), colon != text.startIndex else { return nil } + let portText = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces) + guard let port = Int(portText) else { return nil } + return try? HTTPProxyEndpoint(host: String(text[.. CaptureCertificateController { + let store = CaptureCertificateStore(root: coordinator.paths.root) + guard let endpoint else { + return CaptureCertificateController( + store: store, injector: injector, + verdict: { _ in .unreachable("No proxy address was entered, so no pre-flight check ran.") }) + } + let checker = CertificateChainChecker(probe: ProxyProbe(endpoint: endpoint)) + return CaptureCertificateController( + store: store, injector: injector, + verdict: { await checker.evaluate(certificate: $0) }) + } + + /// What the running session's shell offers, for one action. A session + /// booted with a writable system partition starts with a plain shell — + /// `adb root` is what makes its shell root — so that shape is asked for + /// root first, the way the runner's post-boot rebuild does; a KernelSU + /// session answers `su` without one. + private func sessionCapabilities(_ session: EmulatorSession) async -> GuestSessionCapabilities { + if session.options.writableSystem { + return await WritableSystemInjector().activateRoot(adb: session.adb) + } + return await GuestSessionCapabilities.detect(adb: session.adb) + } + + /// Shown when an action meets a session that offers no root at all. + private static let needsRootedSession = + "This needs a rooted session. Turn on \"Boot with KernelSU\" above and restart, or start a session from a userdebug image with a writable system partition." + + /// Why a recorded certificate cannot be taken out here: the record names + /// the artifact that exists, and this session cannot run the procedure that + /// removes it. + private static func removalNeeds(backend: CaptureBackend) -> String { + switch backend { + case .kernelSUModule: + return "This certificate was installed as a KernelSU module, and only a KernelSU session can take it out. Turn on \"Boot with KernelSU\" above, turn off the writable system, and restart the emulator." + case .writableSystem: + return "This certificate was written straight into the system trust store, and only a session whose shell is root can take it out. Turn on \"Boot with a writable system partition\" below, boot a userdebug image, and restart the emulator." + } + } + + /// Runs one capture action with the section busy and reports its failure in + /// the notice line. + private func captureAction(_ action: @escaping @MainActor () async throws -> Void) { + guard !captureBusy else { return } + captureBusy = true + captureError = nil + Task { + defer { captureBusy = false } + do { try await action() } catch { captureError = error.localizedDescription } + } + } + + private func pickCertificate() { + let panel = NSOpenPanel() + panel.allowedContentTypes = ["pem", "crt", "cer", "der"].compactMap { UTType(filenameExtension: $0) } + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + let endpoint = proxyEndpoint + captureAction { await install(try await CertificateImporter.certificate(fromFile: url), endpoint: endpoint) } + } + + private func usePastedCertificate() { + let text = pasteText, endpoint = proxyEndpoint + captureAction { await install(try await CertificateImporter.certificate(fromPastedText: text), endpoint: endpoint) } + } + + private func fetchFromProxy() { + guard let endpoint = proxyEndpoint else { return } + captureAction { + switch await CertificateAuthorityResolver(probe: ProxyProbe(endpoint: endpoint)).certificate() { + case .found(let certificate, let via): + await install(certificate, endpoint: endpoint, origin: "Fetched from \(via).") + case .failed(let reasons): + captureError = reasons.joined(separator: "\n") + } + } + } + + /// Confirms a resolved certificate and installs it. With an endpoint the + /// pre-flight verdict runs inside the controller and anything it refuses + /// lands in the status line as that verdict; without one the proxy is never + /// contacted, which the user accepts before the install proceeds. + private func install(_ certificate: CaptureCertificate, endpoint: HTTPProxyEndpoint?, origin: String? = nil) async { + guard let session = coordinator.session else { + captureError = "Start the emulator before installing a certificate." + return + } + // The session is asked before the certificate is confirmed, so nothing + // is promised that this session cannot do. + let capabilities = await sessionCapabilities(session) + guard let injector = CertificateInjectorFactory.injector(for: capabilities) else { + captureError = Self.needsRootedSession + return + } + let adb = session.adb + guard confirmInstall(certificate, origin: origin) else { return } + if endpoint == nil, !confirmUnverified() { return } + captureVerdict = nil + do { + let controller = captureController(for: endpoint, injector: injector) + let installation: CaptureInstallation + if endpoint == nil { + installation = try await controller.installUnverified(certificate, adb: adb) + } else { + installation = try await controller.install(certificate, adb: adb) + } + captureState = .installed(installation) + } catch CapturePreflightError.verdict(let verdict) { + captureVerdict = verdict + } catch { + captureError = error.localizedDescription + } + } + + /// Shows the subject and validity the certificate resolved to before + /// anything is installed, the way removing one asks for confirmation. + private func confirmInstall(_ certificate: CaptureCertificate, origin: String?) -> Bool { + let alert = NSAlert() + alert.messageText = "Install \(certificate.subject)?" + var lines = ["Valid from \(certificate.notBefore.formatted(date: .abbreviated, time: .omitted))" + + " to \(certificate.notAfter.formatted(date: .abbreviated, time: .omitted))."] + if let origin { lines.append(origin) } + lines.append("It is added to the system trust store of the running virtual device as \(certificate.storeFileName).") + alert.informativeText = lines.joined(separator: "\n") + alert.addButton(withTitle: "Install") + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + + /// The separate confirmation for an install with no endpoint to check + /// against: the proxy was never contacted, so nothing was verified. + private func confirmUnverified() -> Bool { + let alert = NSAlert() + alert.messageText = "Install without checking the proxy?" + alert.informativeText = "No proxy address was entered, so the proxy was not contacted and this certificate is not verified to be the one the proxy signs with. It is added to the system trust store anyway." + alert.addButton(withTitle: "Install Anyway") + alert.addButton(withTitle: "Cancel") + return alert.runModal() == .alertFirstButtonReturn + } + + private func removeCertificate() { + captureAction { + guard let session = coordinator.session else { + captureError = "Start the emulator before removing a certificate." + return + } + let installation: CaptureInstallation? + do { + installation = try CaptureCertificateStore(root: coordinator.paths.root).load() + } catch { + captureError = error.localizedDescription + return + } + guard let installation else { + captureError = "No certificate is recorded for this virtual device." + return + } + // The record decides which procedure takes the certificate out — + // the artifact it names is the one that exists — and this session + // only decides whether it can run that procedure. Running the other + // shape's would leave the artifact behind while reporting success. + let plan = CertificateInjectorFactory.remover(for: installation.backend, + capabilities: await sessionCapabilities(session)) + switch plan { + case .refused(let backend): + captureError = Self.removalNeeds(backend: backend) + case .injector(let injector): + try await captureController(for: nil, injector: injector).remove(adb: session.adb) + captureState = .idle + captureVerdict = nil + } + } + } + + /// The installed subject and expiry, the pre-flight verdict, or why neither + /// is available. + @ViewBuilder private var captureStatus: some View { + if case .installed(let installation) = captureState { + Label("Installed: \(installation.subject), expires \(installation.notAfter.formatted(date: .abbreviated, time: .omitted))", + systemImage: "checkmark.circle") + } else if case .unavailable(let reason) = captureState { + HostNotice(message: reason) + } else { + Text("No certificate is installed.").font(.caption).foregroundStyle(.secondary) + } + if let captureVerdict { + HostNotice(message: CapturePreflightError.verdict(captureVerdict).localizedDescription) + } + if let captureError { + HostNotice(message: captureError) + } + } } final class SettingsWindowController: NSWindowController { diff --git a/MandroidKit/Capture/CaptureCertificate.swift b/MandroidKit/Capture/CaptureCertificate.swift new file mode 100644 index 0000000..3933716 --- /dev/null +++ b/MandroidKit/Capture/CaptureCertificate.swift @@ -0,0 +1,141 @@ +import Foundation +import Security + +/// A certificate authority the user has chosen to trust inside the guest. +public struct CaptureCertificate: Sendable, Equatable { + public let der: Data + public let pem: String + public let subject: String + public let notBefore: Date + public let notAfter: Date + /// Store entry name: the certificate's subject hash plus ".0". + public let storeFileName: String +} + +public enum CertificateImportError: Error, LocalizedError, Sendable { + case unreadableFile(URL) + case notACertificate + case notACertificateAuthority + case noCertificateInText + case hashFailed(String) + + public var errorDescription: String? { + switch self { + case .unreadableFile(let url): "Could not read \(url.lastPathComponent)." + case .notACertificate: "That file is not a certificate." + case .notACertificateAuthority: "That certificate is not a certificate authority. Choose the root CA your proxy uses to sign." + case .noCertificateInText: "No PEM certificate found in the pasted text." + case .hashFailed(let detail): "Could not compute the certificate store name: \(detail)" + } + } +} + +public enum CertificateImporter { + public static func certificate(fromFile url: URL) async throws -> CaptureCertificate { + guard let data = try? Data(contentsOf: url) else { throw CertificateImportError.unreadableFile(url) } + if let pem = firstPEMBlock(in: String(decoding: data, as: UTF8.self)), let der = der(fromPEM: pem) { + return try await make(fromDER: der) + } + if let certificate = SecCertificateCreateWithData(nil, data as CFData) { + return try await make(fromDER: SecCertificateCopyData(certificate) as Data) + } + // DER files are not always importable directly; retry through OpenSSL. + guard let der = try await convertToDER(data) else { throw CertificateImportError.notACertificate } + return try await make(fromDER: der) + } + + public static func certificate(fromPastedText text: String) async throws -> CaptureCertificate { + guard let pem = firstPEMBlock(in: text), let der = der(fromPEM: pem) else { + throw CertificateImportError.noCertificateInText + } + return try await make(fromDER: der) + } + + static func make(fromDER der: Data) async throws -> CaptureCertificate { + guard let sec = SecCertificateCreateWithData(nil, der as CFData) else { throw CertificateImportError.notACertificate } + let pem = pem(fromDER: der) + guard try await isCertificateAuthority(pem: pem) else { throw CertificateImportError.notACertificateAuthority } + let values = SecCertificateCopyValues(sec, [kSecOIDX509V1ValidityNotBefore, kSecOIDX509V1ValidityNotAfter] as CFArray, nil) as? [CFString: Any] + return CaptureCertificate( + der: der, + pem: pem, + subject: (SecCertificateCopySubjectSummary(sec) as String?) ?? "Unnamed certificate", + notBefore: date(in: values, oid: kSecOIDX509V1ValidityNotBefore) ?? .distantPast, + notAfter: date(in: values, oid: kSecOIDX509V1ValidityNotAfter) ?? .distantFuture, + storeFileName: try await storeFileName(pem: pem)) + } + + static func storeFileName(pem: String) async throws -> String { + let result = try await runOpenSSL(on: Data(pem.utf8), named: "certificate.pem", + options: ["-subject_hash_old", "-noout"]) + let hash = result.stdoutText.trimmingCharacters(in: .whitespacesAndNewlines) + guard result.status == 0, hash.range(of: "^[0-9a-f]{8}$", options: .regularExpression) != nil else { + throw CertificateImportError.hashFailed(result.status == 0 ? "unexpected output \(hash)" : result.stderrText) + } + return "\(hash).0" + } + + static func isCertificateAuthority(pem: String) async throws -> Bool { + let result = try await runOpenSSL(on: Data(pem.utf8), named: "certificate.pem", + options: ["-noout", "-text"]) + guard result.status == 0 else { throw CertificateImportError.notACertificate } + // Only the CA: line of the basic constraints extension decides; text elsewhere + // in the dump, such as a subject that reads "CA:TRUE", must not. + let lines = result.stdoutText + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard let header = lines.firstIndex(where: { $0.hasPrefix("X509v3 Basic Constraints") }) else { return false } + let value = lines[lines.index(after: header)...] + .prefix { !$0.hasPrefix("X509v3 ") } + .first { $0.hasPrefix("CA:") }? + .dropFirst("CA:".count) + .trimmingCharacters(in: .whitespaces) + // A path length constraint follows the value, as in "CA:TRUE, pathlen:0". + return value?.split(separator: ",").first? + .trimmingCharacters(in: .whitespaces) + .caseInsensitiveCompare("TRUE") == .orderedSame + } + + static func date(in values: [CFString: Any]?, oid: CFString) -> Date? { + guard let entry = values?[oid] as? [CFString: Any], let time = entry[kSecPropertyKeyValue] as? CFAbsoluteTime else { return nil } + return Date(timeIntervalSinceReferenceDate: time) + } + + static func firstPEMBlock(in text: String) -> String? { + guard let start = text.range(of: "-----BEGIN CERTIFICATE-----"), + let end = text.range(of: "-----END CERTIFICATE-----", range: start.upperBound.. Data? { + let body = pem.split(separator: "\n") + .filter { !$0.hasPrefix("-----") } + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + return Data(base64Encoded: body, options: .ignoreUnknownCharacters) + } + + static func pem(fromDER der: Data) -> String { + let base64 = der.base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed]) + return "-----BEGIN CERTIFICATE-----\n\(base64)\n-----END CERTIFICATE-----\n" + } + + static func convertToDER(_ data: Data) async throws -> Data? { + let result = try await runOpenSSL(on: data, named: "input.cer", options: ["-outform", "der"]) + guard result.status == 0, !result.stdout.isEmpty else { return nil } + return result.stdout + } + + /// Writes `contents` into a fresh temporary directory, runs `/usr/bin/openssl x509` + /// on it with `options`, and removes the directory again before returning. Output + /// travels through the process streams so nothing outlives the call. + private static func runOpenSSL(on contents: Data, named name: String, options: [String]) async throws -> SubprocessResult { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent(name) + try contents.write(to: file) + return try await Subprocess.run(URL(fileURLWithPath: "/usr/bin/openssl"), + arguments: ["x509", "-in", file.path] + options) + } +} diff --git a/MandroidKit/Capture/CaptureCertificateController.swift b/MandroidKit/Capture/CaptureCertificateController.swift new file mode 100644 index 0000000..a704ae7 --- /dev/null +++ b/MandroidKit/Capture/CaptureCertificateController.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Produces the pre-flight verdict for a certificate. The probe and the +/// checker for the proxy endpoint the user gave are built outside the +/// controller and closed over here, so no endpoint travels through it. +public typealias VerdictProvider = @Sendable (CaptureCertificate) async -> ChainVerdict + +/// A pre-flight verdict that stopped the install before the guest was touched. +/// The verdict is carried whole so the caller can explain which condition it +/// saw instead of reading it out of a message. +public enum CapturePreflightError: Error, LocalizedError, Equatable, Sendable { + case verdict(ChainVerdict) + + public var errorDescription: String? { + switch self { + case .verdict(.trusted): + return "The proxy's certificate chain is trusted." + case .verdict(.foreignIssuer(let subject)): + return "The proxy signs with \(subject), not with the certificate you chose." + case .verdict(.notIntercepted): + return "The chain the proxy presented is one this Mac already trusts, so either the proxy is not intercepting traffic or it signs with a different CA than the one you chose. The certificate was not installed." + case .verdict(.unreachable(let reason)): + return "Could not reach the proxy to check its certificate: \(reason)" + } + } +} + +/// Why an install cannot start while the guest's one record is taken. +public enum CaptureInstallError: Error, LocalizedError, Equatable, Sendable { + case alreadyInstalled(CaptureInstallation) + + public var errorDescription: String? { + switch self { + case .alreadyInstalled(let installation): + return "A certificate is already installed for this virtual device (\(installation.subject)). Remove it first." + } + } +} + +/// What the controller knows about this guest's capture certificate. +public enum CaptureControllerState: Sendable, Equatable { + case idle + case installed(CaptureInstallation) + case unavailable(String) +} + +extension CaptureControllerState { + /// What one record accounts for: the installation it names, nothing when + /// there is none, or why it could not be read. The record is the only + /// account of what the guest holds, so a record that cannot be read is + /// reported rather than read as nothing. + public init(store: CaptureCertificateStore) { + do { + self = try store.load().map(CaptureControllerState.installed) ?? .idle + } catch { + self = .unavailable(error.localizedDescription) + } + } +} + +/// Sequences a certificate install for one guest: pre-flight verdict, +/// injector, record. The verdict provider and the injector are both +/// injected, so the controller itself reaches neither a proxy nor a device. +public actor CaptureCertificateController { + private let store: CaptureCertificateStore + private let injector: any CertificateInjecting + private let verdictProvider: VerdictProvider + + public private(set) var state: CaptureControllerState + + public init(store: CaptureCertificateStore, + injector: any CertificateInjecting, + verdict: @escaping VerdictProvider) { + self.store = store + self.injector = injector + self.verdictProvider = verdict + self.state = CaptureControllerState(store: store) + } + + /// Runs the verdict first: anything other than `.trusted` throws without + /// touching the guest. Otherwise the injector installs the certificate and + /// the record is written. + public func install(_ certificate: CaptureCertificate, adb: any GuestCommandRunning) async throws -> CaptureInstallation { + try refuseWhileARecordExists() + let verdict = await verdictProvider(certificate) + guard verdict == .trusted else { throw CapturePreflightError.verdict(verdict) } + return try await inject(certificate, adb: adb) + } + + /// Installs a certificate the user confirmed by hand, consulting no verdict + /// provider at all, so a guest whose proxy cannot be reached can still be + /// given a certificate. The caller owns telling the user that nothing was + /// verified; everything after the verdict is the same as `install`. + public func installUnverified(_ certificate: CaptureCertificate, adb: any GuestCommandRunning) async throws -> CaptureInstallation { + try refuseWhileARecordExists() + return try await inject(certificate, adb: adb) + } + + /// The store holds exactly one installation, so an install while one is + /// recorded would strand the only certificate the user can still remove. + /// Nothing touches the guest in that case. + private func refuseWhileARecordExists() throws { + if let existing = try store.load() { + throw CaptureInstallError.alreadyInstalled(existing) + } + } + + /// The half both install paths share. The injector leaves the certificate + /// live and verified where apps read it, so the record write is the only + /// remaining step; if it cannot land, the installation is taken back out + /// before the error goes on, so the guest and the record never disagree. + private func inject(_ certificate: CaptureCertificate, adb: any GuestCommandRunning) async throws -> CaptureInstallation { + let installation = try await injector.install(certificate, adb: adb) + do { + try store.save(installation) + } catch { + _ = try? await injector.remove(installation, adb: adb) + throw error + } + state = .installed(installation) + return installation + } + + /// Takes the recorded certificate back out of the guest and forgets it. + public func remove(adb: any GuestCommandRunning) async throws { + if let installation = try store.load() { + try await injector.remove(installation, adb: adb) + } + try store.clear() + state = .idle + } +} diff --git a/MandroidKit/Capture/CaptureCertificateStore.swift b/MandroidKit/Capture/CaptureCertificateStore.swift new file mode 100644 index 0000000..85be2ee --- /dev/null +++ b/MandroidKit/Capture/CaptureCertificateStore.swift @@ -0,0 +1,29 @@ +import Foundation + +/// The record of the capture certificate this Mac put into one guest, kept +/// next to the other per-AVD state so a later session can check it and take it +/// out again. The root is supplied by the caller; this file only names the +/// entry inside it. +public struct CaptureCertificateStore: Sendable { + private let file: URL + + public init(root: URL) { file = root.appendingPathComponent("capture-certificate.json") } + + /// The recorded installation, or nil when nothing was recorded yet. + public func load() throws -> CaptureInstallation? { + guard FileManager.default.fileExists(atPath: file.path) else { return nil } + return try JSONDecoder().decode(CaptureInstallation.self, from: Data(contentsOf: file)) + } + + public func save(_ installation: CaptureInstallation) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(installation).write(to: file, options: .atomic) + } + + public func clear() throws { + guard FileManager.default.fileExists(atPath: file.path) else { return } + try FileManager.default.removeItem(at: file) + } +} diff --git a/MandroidKit/Capture/CertificateAuthorityResolver.swift b/MandroidKit/Capture/CertificateAuthorityResolver.swift new file mode 100644 index 0000000..4f75925 --- /dev/null +++ b/MandroidKit/Capture/CertificateAuthorityResolver.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum ResolverOutcome: Sendable { + case found(CaptureCertificate, via: String) + case failed([String]) +} + +/// Automatic acquisition. Manual file selection and pasted PEM do not come +/// through here; they need no proxy. +public struct CertificateAuthorityResolver: Sendable { + /// Endpoints that debugging proxies answer themselves. + public static let wellKnownEndpoints: [URL] = [ + URL(string: "http://mitm.it/cert/pem")!, + URL(string: "http://chls.pro/ssl")!, + ] + + public static let canaryHost = "example.com" + + private let probe: any ProxyProbing + + public init(probe: any ProxyProbing) { + self.probe = probe + } + + public func certificate() async -> ResolverOutcome { + var reasons: [String] = [] + + for url in Self.wellKnownEndpoints { + do { + let response = try await probe.fetch(url) + guard response.status == 200, let pem = CertificateImporter.firstPEMBlock(in: String(decoding: response.body, as: UTF8.self)), + let der = CertificateImporter.der(fromPEM: pem) else { + reasons.append("\(url.host ?? url.absoluteString) did not return a certificate") + continue + } + return .found(try await CertificateImporter.make(fromDER: der), via: url.absoluteString) + } catch { + reasons.append("\(url.host ?? url.absoluteString): \(error.localizedDescription)") + } + } + + do { + let chain = try await probe.presentedChain(host: Self.canaryHost) + guard chain.count >= 2 else { + reasons.append("the proxy sends only a leaf certificate, so the signing CA cannot be derived") + return .failed(reasons) + } + guard let issuer = chain.last else { + reasons.append("the handshake carried no issuer certificate") + return .failed(reasons) + } + return .found(try await CertificateImporter.make(fromDER: issuer), via: "the TLS chain for \(Self.canaryHost)") + } catch { + reasons.append("\(Self.canaryHost): \(error.localizedDescription)") + } + + return .failed(reasons) + } +} diff --git a/MandroidKit/Capture/CertificateChainChecker.swift b/MandroidKit/Capture/CertificateChainChecker.swift new file mode 100644 index 0000000..eaf65c5 --- /dev/null +++ b/MandroidKit/Capture/CertificateChainChecker.swift @@ -0,0 +1,110 @@ +import Foundation +import Security + +public enum ChainVerdict: Sendable, Equatable { + /// The proxy's leaf chains to the certificate we are about to install. + case trusted + /// The proxy signs with a different CA; the associated value is that CA's subject. + case foreignIssuer(String) + /// The chain verifies against this Mac's own trust settings without our + /// anchor: either the proxy is not intercepting, or it signs with a CA this + /// Mac already trusts and not with the chosen one. + case notIntercepted + case unreachable(String) +} + +public struct CertificateChainChecker: Sendable { + public typealias AnchorEvaluation = @Sendable ([Data]) -> Bool + + private let probe: any ProxyProbing + private let canaryHost: String + private let systemTrusted: AnchorEvaluation + + public init(probe: any ProxyProbing, + canaryHost: String = CertificateAuthorityResolver.canaryHost, + systemTrusted: @escaping AnchorEvaluation = CertificateChainChecker.evaluatesWithSystemAnchors) { + self.probe = probe + self.canaryHost = canaryHost + self.systemTrusted = systemTrusted + } + + public func evaluate(certificate: CaptureCertificate) async -> ChainVerdict { + let chain: [Data] + do { + chain = try await probe.presentedChain(host: canaryHost) + } catch { + return .unreachable(error.localizedDescription) + } + guard !chain.isEmpty else { return .unreachable("The proxy presented no certificate.") } + // The anchor decides first: a debugging CA can also be installed in the host's + // keychain, and interception must still read as interception then. + if verifies(chain: chain, anchors: [certificate.der]) { return .trusted } + if systemTrusted(chain) { return .notIntercepted } + return .foreignIssuer(issuerSubject(of: chain) ?? "an issuer this check could not name") + } + + /// A handshake yields DER; PEM is accepted too, since a chain assembled from a + /// certificate file the user holds arrives in that encoding. + private static func secCertificate(from data: Data) -> SecCertificate? { + if let certificate = SecCertificateCreateWithData(nil, data as CFData) { return certificate } + guard let pem = CertificateImporter.firstPEMBlock(in: String(decoding: data, as: UTF8.self)), + let der = CertificateImporter.der(fromPEM: pem) else { return nil } + return SecCertificateCreateWithData(nil, der as CFData) + } + + /// The chain of trust exactly as presented, leaf first and with no anchors of its + /// own: each caller sets the roots it means to accept. The whole chain goes in + /// because a proxy may sign the leaf with an intermediate, which cannot be + /// verified from the leaf alone. + private static func trust(for chain: [Data]) -> SecTrust? { + let presented = chain.compactMap(secCertificate(from:)) + guard !presented.isEmpty else { return nil } + var trust: SecTrust? + guard SecTrustCreateWithCertificates(presented as CFArray, SecPolicyCreateBasicX509(), &trust) == errSecSuccess else { return nil } + return trust + } + + private func verifies(chain: [Data], anchors: [Data]) -> Bool { + var anchorCertificates: [SecCertificate] = [] + for der in anchors { + guard let anchor = Self.secCertificate(from: der) else { return false } + anchorCertificates.append(anchor) + } + guard let trust = Self.trust(for: chain) else { return false } + SecTrustSetAnchorCertificates(trust, anchorCertificates as CFArray) + SecTrustSetAnchorCertificatesOnly(trust, true) + return SecTrustEvaluateWithError(trust, nil) + } + + /// The issuer's name, either way the handshake can show it: the subject of + /// the issuer certificate when the chain carries one, or the issuer name + /// the leaf states when the proxy sent the leaf alone. + private func issuerSubject(of chain: [Data]) -> String? { + if chain.count >= 2, let der = chain.last, let certificate = Self.secCertificate(from: der) { + return SecCertificateCopySubjectSummary(certificate) as String? + } + guard let der = chain.first, let leaf = Self.secCertificate(from: der) else { return nil } + return Self.issuerName(of: leaf) + } + + /// The common name of the issuer a certificate names for itself, which is + /// what a subject summary of the issuer certificate would have shown. + private static func issuerName(of certificate: SecCertificate) -> String? { + let values = SecCertificateCopyValues(certificate, [kSecOIDX509V1IssuerName] as CFArray, nil) as? [CFString: Any] + guard let entry = values?[kSecOIDX509V1IssuerName] as? [CFString: Any], + let components = entry[kSecPropertyKeyValue] as? [[CFString: Any]] else { return nil } + for component in components { + if let label = component[kSecPropertyKeyLabel] as? String, label == "2.5.4.3", + let value = component[kSecPropertyKeyValue] as? String { + return value + } + } + return nil + } + + /// Default anchor evaluation: the system's own trust settings, no custom anchors. + public static let evaluatesWithSystemAnchors: AnchorEvaluation = { chain in + guard let trust = CertificateChainChecker.trust(for: chain) else { return false } + return SecTrustEvaluateWithError(trust, nil) + } +} diff --git a/MandroidKit/Capture/CertificateInjecting.swift b/MandroidKit/Capture/CertificateInjecting.swift new file mode 100644 index 0000000..f11daf9 --- /dev/null +++ b/MandroidKit/Capture/CertificateInjecting.swift @@ -0,0 +1,47 @@ +import Foundation + +/// How a certificate reaches the guest trust store. +public enum CaptureBackend: String, Codable, Sendable { + case kernelSUModule + case writableSystem +} + +/// A certificate this Mac put into the guest, recorded so it can be checked +/// and taken out again after a restart. +public struct CaptureInstallation: Codable, Sendable, Equatable { + public let fileName: String + public let subject: String + public let notAfter: Date + public let backend: CaptureBackend + public let installedAt: Date +} + +/// The guest command surface an injector runs: root shell commands and raw +/// `adb` arguments. `ADBClient` is the live implementation; a test stands in +/// for it to see what a procedure would run without reaching a device. +public protocol GuestCommandRunning: Sendable { + func shell(_ command: String, timeout: Duration) async throws -> String + func run(_ args: [String], timeout: Duration) async throws -> String +} + +extension ADBClient: GuestCommandRunning {} + +extension GuestCommandRunning { + @discardableResult + public func shell(_ command: String) async throws -> String { + try await shell(command, timeout: .seconds(30)) + } + + @discardableResult + public func run(_ args: [String]) async throws -> String { + try await run(args, timeout: .seconds(30)) + } +} + +/// The install path for one guest. Implementations own the guest-side +/// procedure; callers only sequence install, verify and remove. +public protocol CertificateInjecting: Sendable { + func install(_ certificate: CaptureCertificate, adb: any GuestCommandRunning) async throws -> CaptureInstallation + func verify(_ installation: CaptureInstallation, adb: any GuestCommandRunning) async throws -> VerificationState + func remove(_ installation: CaptureInstallation, adb: any GuestCommandRunning) async throws +} diff --git a/MandroidKit/Capture/CertificateInjectorFactory.swift b/MandroidKit/Capture/CertificateInjectorFactory.swift new file mode 100644 index 0000000..44f1c1e --- /dev/null +++ b/MandroidKit/Capture/CertificateInjectorFactory.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Picks the injector that matches what the running session can actually do. +/// A shell that is root writes the store directly; a shell that only reaches +/// root through `su` has a KernelSU patch behind it and gets a module; a shell +/// with neither has no path to the store, which is reported as nil so the +/// caller can say why the feature is unavailable rather than letting an +/// injector fail somewhere in the middle of a procedure. +public enum CertificateInjectorFactory { + public static func injector(for capabilities: GuestSessionCapabilities) -> (any CertificateInjecting)? { + switch capabilities { + case .kernelSUShellRoot: KernelSUModuleInjector() + case .adbRoot: WritableSystemInjector() + case .none: nil + } + } + + /// How one recorded installation is taken back out: the injector that owns + /// the artifact the record names, or the record this session cannot act on. + /// The record decides the procedure, not the session shape: the other + /// injector takes out what it can reach and leaves the artifact that + /// exists — a KernelSU module whose boot hook puts the certificate back, or + /// an entry sitting on the writable system image — so a removal that ran + /// the wrong one would report success while the certificate stayed trusted. + public static func remover(for backend: CaptureBackend, + capabilities: GuestSessionCapabilities) -> CertificateRemovalPlan { + switch backend { + case .kernelSUModule: + // Removing the module runs through `su`, which is what the KernelSU + // patch provides; a shell that only got root from `adb root` cannot + // reach it. + guard capabilities == .kernelSUShellRoot else { return .refused(backend) } + return .injector(KernelSUModuleInjector()) + case .writableSystem: + // Removing the entry runs through the daemon's own root shell on + // the writable system partition. + guard capabilities == .adbRoot else { return .refused(backend) } + return .injector(WritableSystemInjector()) + } + } +} + +/// What taking one recorded installation out uses, or why this session cannot +/// do it. +public enum CertificateRemovalPlan: Sendable { + /// The injector that owns the artifact the record names, which this + /// session can run. + case injector(any CertificateInjecting) + /// The session's shell cannot run the procedure this backend needs; the + /// backend is carried so the caller can name the session that can. + case refused(CaptureBackend) +} diff --git a/MandroidKit/Capture/GuestTrustStore.swift b/MandroidKit/Capture/GuestTrustStore.swift new file mode 100644 index 0000000..301230d --- /dev/null +++ b/MandroidKit/Capture/GuestTrustStore.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Where a trust-store entry was found, and with which SELinux label. +public enum VerificationState: Sendable, Equatable { + case absent + /// The entry lists in the shell's own mount namespace but not where apps + /// read the store, so no app can trust the certificate. The label is the + /// one the shell's listing showed. + case notVisibleToApps(label: String) + case present(label: String) +} + +extension VerificationState { + /// Throws unless the entry is live everywhere it has to be, naming the + /// failure mode instead of swallowing it. + public func requireVisibleAfterInstall() throws { + switch self { + case .present: + return + case .notVisibleToApps: + throw MandroidKitError.adb("The certificate store entry is present but not visible in an app namespace.") + case .absent: + throw MandroidKitError.adb("The certificate store entry was not visible after install.") + } + } +} + +/// The guest-side procedure for adding one certificate to the system trust +/// store, kept in one place so the injectors and the acceptance script run +/// the same commands. +public enum GuestTrustStore { + public static let moduleDirectory = "/data/adb/modules/capture-ca" + public static let apexStore = "/apex/com.android.conscrypt/cacerts" + public static let systemStore = "/system/etc/security/cacerts" + + public static func moduleProperties() -> String { + """ + id=capture-ca + name=Capture proxy CA + version=1.0 + versionCode=1 + author=Mandroid + description=Adds a debugging proxy CA to the system trust store at boot. + """ + } + + public static func serviceScript() -> String { + """ + #!/system/bin/sh + # Builds a writable copy of the CA store and binds it over the read-only + # store path in every mount namespace. Runs as root from the module directory. + MODDIR=${0%/*} + STORE=/system/etc/security/cacerts + APEX_STORE=/apex/com.android.conscrypt/cacerts + CERT=$(ls "$MODDIR"/*.0 2>/dev/null | head -1) + [ -f "$CERT" ] || exit 1 + [ -d "$APEX_STORE" ] || APEX_STORE="$STORE" + + LABEL=$(grep -h 'security/cacerts' /system/etc/selinux/plat_file_contexts 2>/dev/null | awk '{print $2}' | head -1) + [ -n "$LABEL" ] || LABEL=u:object_r:system_security_cacerts_file:s0 + + # The stock entries are staged before any mount covers a store path. + # Without an APEX store the source is $STORE itself, and a tmpfs over it + # would leave nothing to copy. The staging starts empty and is gone + # again once the store holds the entries: one that persisted would + # carry a replaced certificate into every later rebuild. + STOCK=$MODDIR/stock + rm -r "$STOCK" 2>/dev/null + mkdir -p "$STOCK" + cp "$APEX_STORE"/* "$STOCK"/ 2>/dev/null + + mount -t tmpfs tmpfs "$STORE" + cp "$STOCK"/* "$STORE"/ 2>/dev/null + cp "$CERT" "$STORE/$(basename "$CERT")" + chown root:root "$STORE"/* 2>/dev/null + chmod 644 "$STORE"/* 2>/dev/null + chmod 755 "$STORE" + chcon -R "$LABEL" "$STORE" 2>/dev/null + rm -r "$STOCK" + + seen="" + for pid in $(ls /proc | grep -E '^[0-9]+$'); do + ns="/proc/$pid/ns/mnt" + target=$(readlink "$ns" 2>/dev/null) || continue + case " $seen " in *" $target "*) continue ;; esac + seen="$seen $target" + if ! nsenter --mount="$ns" -- /system/bin/mount --bind "$STORE" "$APEX_STORE" 2>/dev/null; then + [ ! -e "$ns" ] || echo "capture-ca: could not bind the store into $target" >&2 + fi + done + true + """ + } + + /// The listing of the entry in the installing shell's own mount namespace. + public static func verifyCommand(fileName: String) -> String { + "ls -lZ \(systemStore)/\(fileName)" + } + + /// The same listing through zygote64's mount namespace, which is where an + /// app reads the store from. An entry that only exists in the shell's + /// namespace never reaches an app, so this is the listing that decides + /// whether apps can trust the certificate at all. The store path is probed + /// the way the service script resolves it: without an APEX store the apps + /// read the system store, and the listing goes there rather than failing. + public static func zygoteVerifyCommand(fileName: String) -> String { + "APEX_STORE=\(apexStore); STORE=\(systemStore); [ -d \"$APEX_STORE\" ] || APEX_STORE=\"$STORE\"; nsenter --mount=/proc/$(pidof zygote64)/ns/mnt -- ls -lZ \"$APEX_STORE/\(fileName)\"" + } + + /// The SELinux label of a store entry in one `ls -lZ` listing, or nil when + /// the listing is not that entry: a regular-file line naming it, owned by + /// root, carrying a SELinux label. ls's own error text never parses as an + /// entry. + public static func entryLabel(in listing: String, fileName: String) -> String? { + let line = listing + .replacingOccurrences(of: "\r", with: "") + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .first { !$0.isEmpty } ?? "" + guard line.hasPrefix("-") else { return nil } + let fields = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + // ls -lZ: mode, links, owner, group, SELinux context, size, date, name. + guard fields.count >= 5, fields[2] == "root" else { return nil } + guard let label = fields.first(where: { $0.hasPrefix("u:object_r:") }) else { return nil } + guard let path = fields.last, path.split(separator: "/").last.map(String.init) == fileName else { return nil } + return label + } + + /// What the two listings together say: the entry absent from the shell's + /// namespace, present there but not where apps read it, or present and + /// labelled in both. + public static func parseVerification(shellListing: String, appListing: String, fileName: String) -> VerificationState { + guard let label = entryLabel(in: shellListing, fileName: fileName) else { return .absent } + guard entryLabel(in: appListing, fileName: fileName) != nil else { + return .notVisibleToApps(label: label) + } + return .present(label: label) + } + + /// The guest script that takes one recorded install back out: the entry the + /// record names, the binds that published it, and the module that would + /// rebuild it at the next boot. The module identifier is a constant because + /// one session holds one capture module; the entry name is what varies per + /// record and what the script is rendered from. The script is inlined into + /// `su -c '