diff --git a/pass.xcodeproj/project.pbxproj b/pass.xcodeproj/project.pbxproj index 4b5755f..807205a 100644 --- a/pass.xcodeproj/project.pbxproj +++ b/pass.xcodeproj/project.pbxproj @@ -114,11 +114,6 @@ 5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0E27AF6FCA00A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 5F9D7B0F27AF6FD200A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; - 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */; }; - 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */; }; - 8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */; }; - 8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */; }; - 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = 8AD8EBF22F5E268D007475AB /* Fixtures */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; }; @@ -200,7 +195,7 @@ DC4914961E434301007FF592 /* LabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914941E434301007FF592 /* LabelTableViewCell.swift */; }; DC4914991E434600007FF592 /* PasswordDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */; }; DC5F385B1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */; }; - DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* PersistenceController.swift */; }; + DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; }; DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; }; @@ -427,11 +422,6 @@ 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = ""; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = ""; }; 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; - 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKeychainTest.swift; sourceTree = ""; }; - 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = ""; }; - 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPGPInterface.swift; sourceTree = ""; }; - 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPAgentLowLevelTests.swift; sourceTree = ""; }; - 8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = ""; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = ""; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = ""; }; 9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = ""; }; @@ -508,7 +498,7 @@ DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = ""; }; DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = ""; }; DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = ""; }; - DC6474522D20DD0C004B4BBC /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = ""; }; DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = ""; }; @@ -634,7 +624,6 @@ 301F6464216164670071A4CE /* Helpers */ = { isa = PBXGroup; children = ( - 8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */, 3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */, ); path = Helpers; @@ -772,30 +761,6 @@ path = Crypto; sourceTree = ""; }; - 8A4716702F5EF7A900C7A64D /* Controllers */ = { - isa = PBXGroup; - children = ( - 8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */, - ); - path = Controllers; - sourceTree = ""; - }; - 8AB3AD8E2F615FD70081DE16 /* Mocks */ = { - isa = PBXGroup; - children = ( - 8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */, - ); - path = Mocks; - sourceTree = ""; - }; - 8AB3AD8F2F61600B0081DE16 /* LowLevel */ = { - isa = PBXGroup; - children = ( - 8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */, - ); - path = LowLevel; - sourceTree = ""; - }; 9A58664F25AADB66006719C2 /* Services */ = { isa = PBXGroup; children = ( @@ -914,15 +879,11 @@ A26075861EEC6F34005DB03E /* passKitTests */ = { isa = PBXGroup; children = ( - 8A4716702F5EF7A900C7A64D /* Controllers */, DC64745A2D29BD43004B4BBC /* CoreData */, 30A86F93230F235800F821A4 /* Crypto */, 30BAC8C322E3BA4300438475 /* Testbase */, 30697C5521F63F870064FCAC /* Extensions */, - 8AD8EBF22F5E268D007475AB /* Fixtures */, 301F6464216164670071A4CE /* Helpers */, - 8AB3AD8F2F61600B0081DE16 /* LowLevel */, - 8AB3AD8E2F615FD70081DE16 /* Mocks */, 30C015A7214ED378005BB6DF /* Models */, 30C015A6214ED32A005BB6DF /* Parser */, 30B4C7BB24085A3C008B86F7 /* Passwords */, @@ -952,7 +913,7 @@ children = ( 30697C3121F63C8B0064FCAC /* PasscodeLockPresenter.swift */, 30697C3221F63C8B0064FCAC /* PasscodeLockViewController.swift */, - DC6474522D20DD0C004B4BBC /* PersistenceController.swift */, + DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */, ); path = Controllers; sourceTree = ""; @@ -1331,10 +1292,14 @@ TargetAttributes = { 30A69944240EED5E00B7D967 = { CreatedOnToolsVersion = 11.3; + DevelopmentTeam = 4WDM8E95VU; + ProvisioningStyle = Manual; }; A239F5942158C08B00576CBF = { CreatedOnToolsVersion = 10.0; + DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; + ProvisioningStyle = Manual; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1357,7 +1322,9 @@ }; A26700231EEC466A00176B8A = { CreatedOnToolsVersion = 8.3.3; + DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; + ProvisioningStyle = Manual; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1369,13 +1336,16 @@ }; DC13B14D1E8640810097803F = { CreatedOnToolsVersion = 8.3; + DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; ProvisioningStyle = Automatic; TestTargetID = DC917BD21E2E8231000FDF54; }; DC917BD21E2E8231000FDF54 = { CreatedOnToolsVersion = 8.2.1; + DevelopmentTeam = 4WDM8E95VU; LastSwiftMigration = 1020; + ProvisioningStyle = Manual; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1466,7 +1436,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8AD8EBF32F5E2723007475AB /* Fixtures in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1644,7 +1613,7 @@ 3087574F2343E42A00B971A2 /* Colors.swift in Sources */, 30697C2C21F63C5A0064FCAC /* FileManagerExtension.swift in Sources */, 30697C3321F63C8B0064FCAC /* PasscodeLockPresenter.swift in Sources */, - DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */, + DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */, 30697C3D21F63C990064FCAC /* UIViewExtension.swift in Sources */, 30697C3A21F63C990064FCAC /* UIViewControllerExtension.swift in Sources */, 30697C2E21F63C5A0064FCAC /* Utils.swift in Sources */, @@ -1663,17 +1632,13 @@ 30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */, 30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */, 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */, - 8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, - 8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */, - 8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, - 8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */, 30695E2524FAEF2600C9D46E /* GitCredentialTest.swift in Sources */, 30BAC8C622E3BAAF00438475 /* TestBase.swift in Sources */, 30B04860209A5141001013CA /* PasswordTest.swift in Sources */, @@ -1921,10 +1886,11 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -1938,9 +1904,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios.shortcuts; + PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios.shortcuts; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.shortcuts"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -1956,10 +1922,11 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -1972,9 +1939,9 @@ ); MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios.shortcuts; + PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios.shortcuts; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.shortcuts"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2013,6 +1980,7 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -2036,7 +2004,7 @@ MARKETING_VERSION = 0.19.0; MTL_ENABLE_DEBUG_INFO = NO; OTHER_SWIFT_FLAGS = "-D BETA"; - PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforiosbeta; + PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforiosbeta; PRODUCT_NAME = "Pass Beta"; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -2054,9 +2022,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIconBeta; CODE_SIGN_ENTITLEMENTS = pass/passBeta.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2072,6 +2042,8 @@ OTHER_LDFLAGS = "${inherited}"; OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; + PROVISIONING_PROFILE = "ee6e841d-ef77-4f00-b534-d7f1fd25dc1d"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta"; STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_VERSION = 5.0; @@ -2085,7 +2057,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passTests/Info.plist; @@ -2114,10 +2086,9 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passExtension/passBetaExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2134,7 +2105,8 @@ OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_NAME = passExtension; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE = "cbd86628-6f3e-40f3-b518-20d2330db545"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.find-login-action-extension"; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2187,7 +2159,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passKitTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -2215,10 +2186,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passBetaAutoFillExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2233,9 +2204,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforiosbeta.auto-fill-credential-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforiosbeta.auto-fill-credential-extension"; PRODUCT_NAME = passAutoFillExtension; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.auto-fill-credential-extension"; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2252,10 +2223,11 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passShortcuts/passBetaShortcuts.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2269,9 +2241,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforiosbeta.shortcuts; + PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforiosbeta.shortcuts; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.shortcuts"; SKIP_INSTALL = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2287,10 +2259,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2305,9 +2277,9 @@ MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforios.auto-fill-credential-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforios.auto-fill-credential-extension"; PRODUCT_NAME = passAutoFillExtension; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.auto-fill-credential-extension"; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2324,10 +2296,10 @@ CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -2341,9 +2313,9 @@ ); MARKETING_VERSION = "$(MARKETING_VERSION)"; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforios.auto-fill-credential-extension"; + PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforios.auto-fill-credential-extension"; PRODUCT_NAME = passAutoFillExtension; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.auto-fill-credential-extension"; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2433,7 +2405,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passKitTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -2458,7 +2429,6 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passKitTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -2485,10 +2455,9 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2504,7 +2473,8 @@ OTHER_CFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_NAME = passExtension; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE = "d25c9029-bca6-4b2d-b04e-4abc9d232740"; + PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.find-login-action-extension"; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2521,10 +2491,9 @@ CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2540,7 +2509,8 @@ OTHER_CFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_NAME = passExtension; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE = "cbd86628-6f3e-40f3-b518-20d2330db545"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.find-login-action-extension"; SKIP_INSTALL = YES; STRIP_INSTALLED_PRODUCT = NO; SWIFT_VERSION = 5.0; @@ -2554,7 +2524,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passTests/Info.plist; @@ -2579,7 +2549,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = passTests/Info.plist; @@ -2630,6 +2600,7 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = dwarf; @@ -2659,7 +2630,7 @@ MARKETING_VERSION = 0.19.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios; + PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios; PRODUCT_NAME = Pass; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -2703,6 +2674,7 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 0; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -2725,7 +2697,7 @@ LD_RUNPATH_SEARCH_PATHS = ""; MARKETING_VERSION = 0.19.0; MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios; + PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios; PRODUCT_NAME = Pass; SDKROOT = iphoneos; STRIP_INSTALLED_PRODUCT = NO; @@ -2743,10 +2715,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2761,7 +2734,8 @@ OTHER_CFLAGS = "$(inherited)"; OTHER_LDFLAGS = "${inherited}"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE = "3c4f599a-ce77-4184-b4c4-edebf09cba3b"; + PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios"; STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_VERSION = 5.0; @@ -2774,10 +2748,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements; - CODE_SIGN_STYLE = Automatic; + CODE_SIGN_IDENTITY = "iPhone Developer"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; DEFINES_MODULE = NO; - DEVELOPMENT_TEAM = QLYN3TZMJW; + DEVELOPMENT_TEAM = 4WDM8E95VU; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)"; @@ -2793,7 +2768,8 @@ OTHER_LDFLAGS = "${inherited}"; OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; - PROVISIONING_PROFILE_SPECIFIER = ""; + PROVISIONING_PROFILE = "ee6e841d-ef77-4f00-b534-d7f1fd25dc1d"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios"; STRIP_INSTALLED_PRODUCT = NO; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_VERSION = 5.0; diff --git a/pass/Controllers/PasswordDetailTableViewController.swift b/pass/Controllers/PasswordDetailTableViewController.swift index 37efdd1..98aa66a 100644 --- a/pass/Controllers/PasswordDetailTableViewController.swift +++ b/pass/Controllers/PasswordDetailTableViewController.swift @@ -128,7 +128,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni // alert: cancel or try again let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) - let selectKey = UIAlertAction.selectKey(type: .PRIVATE, controller: self) { action in + let selectKey = UIAlertAction.selectKey(controller: self) { action in self.decryptThenShowPasswordLocalKey(keyID: action.title) } alert.addAction(selectKey) @@ -223,7 +223,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni SVProgressHUD.dismiss() let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) - let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: self) { action in + let selectKey = UIAlertAction.selectKey(controller: self) { action in self.saveEditPassword(password: password, keyID: action.title) } alert.addAction(selectKey) diff --git a/pass/Controllers/SettingsTableViewController.swift b/pass/Controllers/SettingsTableViewController.swift index b0a3e61..b715069 100644 --- a/pass/Controllers/SettingsTableViewController.swift +++ b/pass/Controllers/SettingsTableViewController.swift @@ -89,12 +89,10 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele private func setPGPKeyTableViewCellDetailText() { var label = "NotSet".localize() - var keyIDs = Set((try? PGPAgent.shared.getShortKeyIDs(type: .PRIVATE)) ?? []) - keyIDs.formUnion((try? PGPAgent.shared.getShortKeyIDs(type: .PUBLIC)) ?? []) - - if keyIDs.count == 1 { - label = keyIDs.first ?? "" - } else if keyIDs.count > 1 { + let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? [] + if keyID.count == 1 { + label = keyID.first ?? "" + } else if keyID.count > 1 { label = "Multiple" } if Defaults.isYubiKeyEnabled { diff --git a/pass/Services/PasswordDecryptor.swift b/pass/Services/PasswordDecryptor.swift index e18aaba..49e7845 100644 --- a/pass/Services/PasswordDecryptor.swift +++ b/pass/Services/PasswordDecryptor.swift @@ -30,11 +30,8 @@ func decryptPassword( } DispatchQueue.global(qos: .userInteractive).async { do { - guard let passwordEntity = PasswordStore.shared.fetchPasswordEntity(with: passwordPath) else { - throw AppError.decryption - } let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller) - let decryptedPassword = try PasswordStore.shared.decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) DispatchQueue.main.async { completion(decryptedPassword) @@ -43,7 +40,7 @@ func decryptPassword( DispatchQueue.main.async { let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) - let selectKey = UIAlertAction.selectKey(type: PGPKey.PRIVATE, controller: controller) { action in + let selectKey = UIAlertAction.selectKey(controller: controller) { action in decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion) } alert.addAction(selectKey) diff --git a/pass/Services/PasswordEncryptor.swift b/pass/Services/PasswordEncryptor.swift index 6159881..254f045 100644 --- a/pass/Services/PasswordEncryptor.swift +++ b/pass/Services/PasswordEncryptor.swift @@ -19,7 +19,7 @@ func encryptPassword(in controller: UIViewController, with password: Password, k DispatchQueue.main.async { let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) - let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: controller) { action in + let selectKey = UIAlertAction.selectKey(controller: controller) { action in encryptPassword(in: controller, with: password, keyID: action.title, completion: completion) } alert.addAction(selectKey) diff --git a/pass/de.lproj/Localizable.strings b/pass/de.lproj/Localizable.strings index 355b200..8177a35 100644 --- a/pass/de.lproj/Localizable.strings +++ b/pass/de.lproj/Localizable.strings @@ -72,7 +72,6 @@ "KeyImportError." = "Schlüssel kann nicht importiert werden."; "FileNotFoundError." = "Die Datei '%@' kann nicht gelesen werden."; "PasswordDuplicatedError." = "Passwort kann nicht hinzugefügt werden; es existiert bereits."; -"CannotDeleteNonEmptyDirectoryError." = "Ordner muss erst leer sein um gelöscht werden zu können."; "GitResetError." = "Der zuletzt synchronisierte Commit kann nicht identifiziert werden."; "GitCreateSignatureError." = "Es konnte keine valide Signatur für den Author/Committer angelegt werden."; "GitPushNotSuccessfulError." = "Die Übertragung der lokalen Änderungen war nicht erfolgreich. Stelle bitte sicher, dass auf dem Remote-Repository alle Änderungen commitet sind."; diff --git a/pass/en.lproj/Localizable.strings b/pass/en.lproj/Localizable.strings index a4f74e3..5f26010 100644 --- a/pass/en.lproj/Localizable.strings +++ b/pass/en.lproj/Localizable.strings @@ -73,7 +73,6 @@ "KeyImportError." = "Cannot import the key."; "FileNotFoundError." = "File '%@' cannot be read."; "PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; -"CannotDeleteNonEmptyDirectoryError." = "Delete passwords from the directory before deleting the directory itself."; "GitResetError." = "Cannot identify the latest synced commit."; "GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository."; diff --git a/pass/pass.entitlements b/pass/pass.entitlements index a844039..26deb49 100644 --- a/pass/pass.entitlements +++ b/pass/pass.entitlements @@ -2,13 +2,21 @@ + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.developer.nfc.readersession.formats + + TAG + + com.apple.developer.siri + com.apple.security.application-groups - group.org.lysanntranvouez.passforios + group.me.mssun.passforios keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios + $(AppIdentifierPrefix)group.me.mssun.passforios diff --git a/pass/passBeta.entitlements b/pass/passBeta.entitlements index b6ffc4a..1a9efa6 100644 --- a/pass/passBeta.entitlements +++ b/pass/passBeta.entitlements @@ -2,13 +2,21 @@ + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.developer.nfc.readersession.formats + + TAG + + com.apple.developer.siri + com.apple.security.application-groups - group.org.lysanntranvouez.passforiosbeta + group.me.mssun.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta + $(AppIdentifierPrefix)group.me.mssun.passforiosbeta diff --git a/passAutoFillExtension/passAutoFillExtension.entitlements b/passAutoFillExtension/passAutoFillExtension.entitlements index a844039..d58dedc 100644 --- a/passAutoFillExtension/passAutoFillExtension.entitlements +++ b/passAutoFillExtension/passAutoFillExtension.entitlements @@ -2,13 +2,15 @@ + com.apple.developer.authentication-services.autofill-credential-provider + com.apple.security.application-groups - group.org.lysanntranvouez.passforios + group.me.mssun.passforios keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios + $(AppIdentifierPrefix)group.me.mssun.passforios diff --git a/passAutoFillExtension/passBetaAutoFillExtension.entitlements b/passAutoFillExtension/passBetaAutoFillExtension.entitlements index b6ffc4a..272d9c8 100644 --- a/passAutoFillExtension/passBetaAutoFillExtension.entitlements +++ b/passAutoFillExtension/passBetaAutoFillExtension.entitlements @@ -2,13 +2,15 @@ + com.apple.developer.authentication-services.autofill-credential-provider + com.apple.security.application-groups - group.org.lysanntranvouez.passforiosbeta + group.me.mssun.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta + $(AppIdentifierPrefix)group.me.mssun.passforiosbeta diff --git a/passExtension/passBetaExtension.entitlements b/passExtension/passBetaExtension.entitlements index b6ffc4a..9bcfd12 100644 --- a/passExtension/passBetaExtension.entitlements +++ b/passExtension/passBetaExtension.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.org.lysanntranvouez.passforiosbeta + group.me.mssun.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta + $(AppIdentifierPrefix)group.me.mssun.passforiosbeta diff --git a/passExtension/passExtension.entitlements b/passExtension/passExtension.entitlements index a844039..13c5967 100644 --- a/passExtension/passExtension.entitlements +++ b/passExtension/passExtension.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.org.lysanntranvouez.passforios + group.me.mssun.passforios keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios + $(AppIdentifierPrefix)group.me.mssun.passforios diff --git a/passKit/Controllers/PersistenceController.swift b/passKit/Controllers/CoreDataStack.swift similarity index 90% rename from passKit/Controllers/PersistenceController.swift rename to passKit/Controllers/CoreDataStack.swift index 5d05001..0452259 100644 --- a/passKit/Controllers/PersistenceController.swift +++ b/passKit/Controllers/CoreDataStack.swift @@ -1,5 +1,5 @@ // -// PersistenceController.swift +// CoreDataStack.swift // passKit // // Created by Mingshen Sun on 12/28/24. @@ -18,19 +18,19 @@ public class PersistenceController { let container: NSPersistentContainer - init(storeURL: URL? = nil) { + init(isUnitTest: Bool = false) { self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel) let description = container.persistentStoreDescriptions.first description?.shouldMigrateStoreAutomatically = false description?.shouldInferMappingModelAutomatically = false - description?.url = storeURL ?? URL(fileURLWithPath: Globals.dbPath) + if isUnitTest { + description?.url = URL(fileURLWithPath: "/dev/null") + } else { + description?.url = URL(fileURLWithPath: Globals.dbPath) + } setup() } - static func forUnitTests() -> PersistenceController { - PersistenceController(storeURL: URL(fileURLWithPath: "/dev/null")) - } - func setup() { container.loadPersistentStores { _, error in if error != nil { diff --git a/passKit/Crypto/GopenPGPInterface.swift b/passKit/Crypto/GopenPGPInterface.swift index 3917df1..34f0622 100644 --- a/passKit/Crypto/GopenPGPInterface.swift +++ b/passKit/Crypto/GopenPGPInterface.swift @@ -16,7 +16,6 @@ struct GopenPGPInterface: PGPInterface { private var publicKeys: [String: CryptoKey] = [:] private var privateKeys: [String: CryptoKey] = [:] - private var privateSubkeyToKeyIDMapping: [String: String] = [:] // value is the key in privateKeys map init(publicArmoredKey: String, privateArmoredKey: String) throws { let pubKeys = extractKeysFromArmored(str: publicArmoredKey) @@ -41,24 +40,7 @@ struct GopenPGPInterface: PGPInterface { } throw AppError.keyImport } - - let keyID = cryptoKey.getFingerprint().lowercased() - privateKeys[keyID] = cryptoKey - - guard let subkeyIDsJSON = HelperPassGetHexSubkeyIDsJSON(cryptoKey) else { - guard error == nil else { - throw error! - } - throw AppError.keyImport - } - do { - let subkeyIDs = try JSONDecoder().decode([String].self, from: subkeyIDsJSON) - for subkeyID in subkeyIDs { - privateSubkeyToKeyIDMapping[subkeyID] = keyID - } - } catch { - throw AppError.keyImport - } + privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey } } @@ -88,13 +70,15 @@ struct GopenPGPInterface: PGPInterface { privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } } - func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { - let message = createPGPMessage(from: encryptedData) - guard let message else { - throw AppError.decryption - } + func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? { + let key: CryptoKey? = { + if let keyID { + return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value + } + return privateKeys.first?.value + }() - guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else { + guard let privateKey = key else { throw AppError.decryption } @@ -103,7 +87,6 @@ struct GopenPGPInterface: PGPInterface { try privateKey.isLocked(&isLocked) var unlockedKey: CryptoKey! if isLocked.boolValue { - let passphrase = passPhraseForKey(privateKey.getFingerprint()) unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8)) } else { unlockedKey = privateKey @@ -117,43 +100,33 @@ struct GopenPGPInterface: PGPInterface { throw AppError.decryption } + let message = createPGPMessage(from: encryptedData) return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data } catch { throw Self.errorMapping[error.localizedDescription, default: error] } } - func encryptWithAllKeys(plainData: Data) throws -> Data { - let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) } - return try encrypt(plainData: plainData, keyIDs: keyIDs) - } - - func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { - let keys: [CryptoKey] = try keyIDs.map { keyID in - guard let key = publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value else { - throw AppError.pgpPublicKeyNotFound(keyID: keyID) + func encrypt(plainData: Data, keyID: String?) throws -> Data { + let key: CryptoKey? = { + if let keyID { + return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value } - return key - } - guard let firstKey = keys.first else { + return publicKeys.first?.value + }() + + guard let publicKey = key else { throw AppError.encryption } - let otherKeys = keys.dropFirst() var error: NSError? - guard let keyRing = CryptoNewKeyRing(firstKey, &error) else { + + guard let keyRing = CryptoNewKeyRing(publicKey, &error) else { guard error == nil else { throw error! } throw AppError.encryption } - do { - try otherKeys.forEach { key in - try keyRing.add(key) - } - } catch { - throw AppError.encryption - } let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil) if Defaults.encryptInArmored { @@ -167,44 +140,12 @@ struct GopenPGPInterface: PGPInterface { return encryptedData.getBinary()! } - func getKeyIDs(type: PGPKey) -> [String] { - switch type { - case .PUBLIC: - return publicKeys.keys.map { $0.uppercased() } - case .PRIVATE: - return privateKeys.keys.map { $0.uppercased() } - } + var keyID: [String] { + publicKeys.keys.map { $0.uppercased() } } - func getShortKeyIDs(type: PGPKey) -> [String] { - getKeyIDs(type: type).map { $0.suffix(8).uppercased() } - } - - private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? { - var keyIDCandidates: any Collection = privateKeys.keys - do { - if let encryptionKeysJSON = message.getHexEncryptionKeyIDsJson() { - // these are the subkeys (encryption keys), not the primaries keys (whose fingerprints we have in the privateKeys map), - // so we need to map them back to the primary keyIDs using privateSubkeyToKeyIDMapping - let validSubkeys = try JSONDecoder().decode([String].self, from: encryptionKeysJSON) - let validKeyIDs = validSubkeys.compactMap { privateSubkeyToKeyIDMapping[$0] } - if #available(iOSApplicationExtension 16.0, *) { - assert(validKeyIDs.isEmpty || !Set(keyIDCandidates).isDisjoint(with: validKeyIDs)) - } - keyIDCandidates = validKeyIDs - } - } catch { - // fall back to legacy approach of trying first in privateKeys (or preferring hint) - } - - if let keyIDHint { - keyIDCandidates = keyIDCandidates.filter { key in key.hasSuffix(keyIDHint.lowercased()) } - } - guard let selectedKeyID = keyIDCandidates.first else { - throw keyIDHint != nil ? AppError.keyExpiredOrIncompatible : AppError.decryption - } - - return privateKeys[selectedKeyID] + var shortKeyID: [String] { + publicKeys.keys.map { $0.suffix(8).uppercased() } } } diff --git a/passKit/Crypto/ObjectivePGPInterface.swift b/passKit/Crypto/ObjectivePGPInterface.swift index e1425c3..768d785 100644 --- a/passKit/Crypto/ObjectivePGPInterface.swift +++ b/passKit/Crypto/ObjectivePGPInterface.swift @@ -24,29 +24,12 @@ struct ObjectivePGPInterface: PGPInterface { } } - func decrypt(encryptedData: Data, keyIDHint _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { - try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in - guard let selectedKey else { - return nil - } - return passPhraseForKey(selectedKey.keyID.longIdentifier) - } + func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { + try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } } - func encryptWithAllKeys(plainData: Data) throws -> Data { - let keys = keyring.keys.filter { $0.isPublic && $0.isSecret } - return try encrypt(plainData: plainData, keyIDs: keys.map(\.keyID.longIdentifier)) - } - - func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { - let keys = try keyIDs.map { keyID in - guard let key = keyring.findKey(keyID) else { - throw AppError.pgpPublicKeyNotFound(keyID: keyID) - } - return key - } - - let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keys, passphraseForKey: nil) + func encrypt(plainData: Data, keyID _: String?) throws -> Data { + let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) if Defaults.encryptInArmored { return Armor.armored(encryptedData, as: .message).data(using: .ascii)! } @@ -61,20 +44,11 @@ struct ObjectivePGPInterface: PGPInterface { keyring.findKey(keyID)?.isSecret ?? false } - func getKeyIDs(type: PGPKey) -> [String] { - getKeys(type: type).map(\.keyID.longIdentifier) + var keyID: [String] { + keyring.keys.map(\.keyID.longIdentifier) } - func getShortKeyIDs(type: PGPKey) -> [String] { - getKeys(type: type).map(\.keyID.shortIdentifier) - } - - private func getKeys(type: PGPKey) -> [Key] { - switch type { - case .PUBLIC: - keyring.keys.filter(\.isPublic) - case .PRIVATE: - keyring.keys.filter(\.isSecret) - } + var shortKeyID: [String] { + keyring.keys.map(\.keyID.shortIdentifier) } } diff --git a/passKit/Crypto/PGPAgent.swift b/passKit/Crypto/PGPAgent.swift index 6ee8c2b..66fbbed 100644 --- a/passKit/Crypto/PGPAgent.swift +++ b/passKit/Crypto/PGPAgent.swift @@ -9,7 +9,7 @@ public class PGPAgent { public static let shared = PGPAgent() - let keyStore: KeyStore + private let keyStore: KeyStore private var pgpInterface: PGPInterface? private var latestDecryptStatus = true @@ -17,11 +17,6 @@ public class PGPAgent { self.keyStore = keyStore } - init(keyStore: KeyStore, pgpInterface: PGPInterface) { - self.keyStore = keyStore - self.pgpInterface = pgpInterface - } - public func initKeys() throws { guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()), let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else { @@ -39,28 +34,30 @@ public class PGPAgent { pgpInterface = nil } - public func isInitialized() -> Bool { - pgpInterface != nil - } - - public func getKeyIDs(type: PGPKey) throws -> [String] { + public func getKeyID() throws -> [String] { try checkAndInit() - return pgpInterface?.getKeyIDs(type: type).sorted() ?? [] + return pgpInterface?.keyID ?? [] } - public func getShortKeyIDs(type: PGPKey) throws -> [String] { + public func getShortKeyID() throws -> [String] { try checkAndInit() - return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? [] + return pgpInterface?.shortKeyID.sorted() ?? [] } - public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { + public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { + // Init keys. try checkAndInit() guard let pgpInterface else { throw AppError.decryption } - if let keyID, !pgpInterface.containsPrivateKey(with: keyID) { - throw AppError.pgpPrivateKeyNotFound(keyID: keyID) + var keyID = keyID + if !pgpInterface.containsPrivateKey(with: keyID) { + if pgpInterface.keyID.count == 1 { + keyID = pgpInterface.keyID.first! + } else { + throw AppError.pgpPrivateKeyNotFound(keyID: keyID) + } } // Remember the previous status and set the current status @@ -68,14 +65,14 @@ public class PGPAgent { latestDecryptStatus = false // Get the PGP key passphrase. - let providePassPhraseForKey = { (selectedKeyID: String) -> String in - if previousDecryptStatus == false { - return requestPGPKeyPassphrase(selectedKeyID) - } - return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID) + var passphrase = "" + if previousDecryptStatus == false { + passphrase = requestPGPKeyPassphrase(keyID) + } else { + passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID) } // Decrypt. - guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else { + guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else { return nil } // The decryption step has succeed. @@ -83,20 +80,50 @@ public class PGPAgent { return result } - public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { + public func encrypt(plainData: Data, keyID: String) throws -> Data { try checkAndInit() guard let pgpInterface else { throw AppError.encryption } - return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs) + var keyID = keyID + if !pgpInterface.containsPublicKey(with: keyID) { + if pgpInterface.keyID.count == 1 { + keyID = pgpInterface.keyID.first! + } else { + throw AppError.pgpPublicKeyNotFound(keyID: keyID) + } + } + return try pgpInterface.encrypt(plainData: plainData, keyID: keyID) } - public func encryptWithAllKeys(plainData: Data) throws -> Data { + public func decrypt(encryptedData: Data, requestPGPKeyPassphrase: (String) -> String) throws -> Data? { + // Remember the previous status and set the current status + let previousDecryptStatus = latestDecryptStatus + latestDecryptStatus = false + // Init keys. + try checkAndInit() + // Get the PGP key passphrase. + var passphrase = "" + if previousDecryptStatus == false { + passphrase = requestPGPKeyPassphrase("") + } else { + passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: "")) ?? requestPGPKeyPassphrase("") + } + // Decrypt. + guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else { + return nil + } + // The decryption step has succeed. + latestDecryptStatus = true + return result + } + + public func encrypt(plainData: Data) throws -> Data { try checkAndInit() guard let pgpInterface else { throw AppError.encryption } - return try pgpInterface.encryptWithAllKeys(plainData: plainData) + return try pgpInterface.encrypt(plainData: plainData, keyID: nil) } public var isPrepared: Bool { diff --git a/passKit/Crypto/PGPInterface.swift b/passKit/Crypto/PGPInterface.swift index 2afce6d..b77831d 100644 --- a/passKit/Crypto/PGPInterface.swift +++ b/passKit/Crypto/PGPInterface.swift @@ -7,15 +7,15 @@ // protocol PGPInterface { - func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? + func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? - // encrypt with all public keys for which we also have a private key - func encryptWithAllKeys(plainData: Data) throws -> Data - func encrypt(plainData: Data, keyIDs: [String]) throws -> Data + func encrypt(plainData: Data, keyID: String?) throws -> Data func containsPublicKey(with keyID: String) -> Bool + func containsPrivateKey(with keyID: String) -> Bool - func getKeyIDs(type: PGPKey) -> [String] - func getShortKeyIDs(type: PGPKey) -> [String] + var keyID: [String] { get } + + var shortKeyID: [String] { get } } diff --git a/passKit/Extensions/UIAlertActionExtension.swift b/passKit/Extensions/UIAlertActionExtension.swift index d258e3b..b49ba07 100644 --- a/passKit/Extensions/UIAlertActionExtension.swift +++ b/passKit/Extensions/UIAlertActionExtension.swift @@ -38,10 +38,10 @@ public extension UIAlertAction { } } - static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { + static func selectKey(controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { UIAlertAction(title: "Select Key", style: .default) { _ in let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet) - try? PGPAgent.shared.getShortKeyIDs(type: type).forEach { keyID in + try? PGPAgent.shared.getShortKeyID().forEach { keyID in let action = UIAlertAction(title: keyID, style: .default, handler: handler) selectKeyAlert.addAction(action) } diff --git a/passKit/Helpers/AppError.swift b/passKit/Helpers/AppError.swift index 28ceb73..8e0aa21 100644 --- a/passKit/Helpers/AppError.swift +++ b/passKit/Helpers/AppError.swift @@ -15,7 +15,6 @@ public enum AppError: Error, Equatable { case keyImport case readingFile(fileName: String) case passwordDuplicated - case cannotDeleteNonEmptyDirectory case gitReset case gitCommit case gitCreateSignature diff --git a/passKit/Helpers/Globals.swift b/passKit/Helpers/Globals.swift index 2135dde..a9242c2 100644 --- a/passKit/Helpers/Globals.swift +++ b/passKit/Helpers/Globals.swift @@ -12,9 +12,9 @@ import UIKit public final class Globals { public static let bundleIdentifier: String = { #if BETA - return "org.lysanntranvouez.passforiosbeta" + return "me.mssun.passforiosbeta" #else - return "org.lysanntranvouez.passforios" + return "me.mssun.passforios" #endif }() diff --git a/passKit/Models/PasswordStore.swift b/passKit/Models/PasswordStore.swift index 65bf31c..495dce2 100644 --- a/passKit/Models/PasswordStore.swift +++ b/passKit/Models/PasswordStore.swift @@ -23,7 +23,6 @@ public class PasswordStore { }() public var storeURL: URL - private let pgpAgent: PGPAgent public var gitRepository: GitRepository? @@ -85,9 +84,8 @@ public class PasswordStore { gitRepository?.numberOfCommits() } - init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) { + init(url: URL = Globals.repositoryURL) { self.storeURL = url - self.pgpAgent = pgpAgent // Migration importExistingKeysIntoKeychain() @@ -275,15 +273,9 @@ public class PasswordStore { } public func delete(passwordEntity: PasswordEntity) throws { - if !passwordEntity.children.isEmpty { - throw AppError.cannotDeleteNonEmptyDirectory - } - let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFilePath = passwordEntity.path - if !passwordEntity.isDir { - try gitRm(path: passwordEntity.path) - } + try gitRm(path: passwordEntity.path) try deletePasswordEntities(passwordEntity: passwordEntity) try deleteDirectoryTree(at: deletedFileURL) try gitCommit(message: "RemovePassword.".localize(deletedFilePath)) @@ -291,11 +283,6 @@ public class PasswordStore { } public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? { - guard !passwordEntity.isDir else { - // caller should ensure this, so this is not a user-facing error - throw AppError.other(message: "Cannot edit a directory") - } - var newPasswordEntity: PasswordEntity? = passwordEntity let url = passwordEntity.fileURL(in: storeURL) @@ -333,11 +320,11 @@ public class PasswordStore { saveUpdatedContext() } - private func saveUpdatedContext() { + public func saveUpdatedContext() { PersistenceController.shared.save() } - private func deleteCoreData() { + public func deleteCoreData() { PasswordEntity.deleteAll(in: context) PersistenceController.shared.save() } @@ -361,14 +348,14 @@ public class PasswordStore { eraseStoreData() // Delete PGP key, SSH key and other secrets from the keychain. - pgpAgent.keyStore.removeAllContent() + AppKeychain.shared.removeAllContent() // Delete default settings. Defaults.removeAll() // Delete cache explicitly. PasscodeLock.shared.delete() - pgpAgent.uninitKeys() + PGPAgent.shared.uninitKeys() } // return the number of discarded commits @@ -397,7 +384,13 @@ public class PasswordStore { public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { let url = passwordEntity.fileURL(in: storeURL) let encryptedData = try Data(contentsOf: url) - let data: Data? = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + let data: Data? = try { + if Defaults.isEnableGPGIDOn { + let keyID = keyID ?? findGPGID(from: url) + return try PGPAgent.shared.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + } + return try PGPAgent.shared.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + }() guard let decryptedData = data else { throw AppError.decryption } @@ -405,20 +398,23 @@ public class PasswordStore { return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText) } - public func encrypt(password: Password, keyID: String? = nil) throws -> Data { - let keyIDs: [String] = { - if let keyID { - return [keyID] - } - if Defaults.isEnableGPGIDOn { - return findGPGIDs(underPath: password.path) - } - return [] - }() - if !keyIDs.isEmpty { - return try pgpAgent.encrypt(plainData: password.plainData, keyIDs: keyIDs) + public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { + guard let passwordEntity = fetchPasswordEntity(with: path) else { + throw AppError.decryption } - return try pgpAgent.encryptWithAllKeys(plainData: password.plainData) + if Defaults.isEnableGPGIDOn { + return try decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + } + return try decrypt(passwordEntity: passwordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + } + + public func encrypt(password: Password, keyID: String? = nil) throws -> Data { + let encryptedDataPath = password.fileURL(in: storeURL) + let keyID = keyID ?? findGPGID(from: encryptedDataPath) + if Defaults.isEnableGPGIDOn { + return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) + } + return try PGPAgent.shared.encrypt(plainData: password.plainData) } public func removeGitSSHKeys() { @@ -429,37 +425,6 @@ public class PasswordStore { AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey()) gitSSHPrivateKeyPassphrase = nil } - - func findGPGIDs(underPath relativePath: String) -> [String] { - guard let gpgIDFileURL = findGPGIDFile(atPath: relativePath) else { - return [] - } - let allKeysSeparatedByNewline = (try? String(contentsOf: gpgIDFileURL)) ?? "" - return allKeysSeparatedByNewline - .split(separator: "\n") - .map { String($0).trimmed } - .filter { !$0.isEmpty } - } - - func findGPGIDFile(atPath relativePath: String) -> URL? { - // Walk up the directory hierarchy, but never escape the store root - let storeRoot = storeURL.absoluteURL.resolvingSymlinksInPath() - var current = storeRoot.appendingPathComponent(relativePath).resolvingSymlinksInPath() - - while current.path.hasPrefix(storeRoot.path) { - let candidate = current.appendingPathComponent(".gpg-id") - var isDir: ObjCBool = false - if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDir), !isDir.boolValue { - return candidate.standardizedFileURL - } - let parent = current.deletingLastPathComponent().resolvingSymlinksInPath() - if parent.path == current.path { - break - } - current = parent - } - return nil - } } extension PasswordStore { @@ -492,3 +457,14 @@ extension PasswordStore { return try gitRepository.commit(signature: gitSignatureForNow, message: message) } } + +func findGPGID(from url: URL) -> String { + var path = url + while !FileManager.default.fileExists(atPath: path.appendingPathComponent(".gpg-id").path), + path.path != "file:///" { + path = path.deletingLastPathComponent() + } + path = path.appendingPathComponent(".gpg-id") + + return (try? String(contentsOf: path))?.trimmed ?? "" +} diff --git a/passKitTests/Controllers/PersistenceControllerTest.swift b/passKitTests/Controllers/PersistenceControllerTest.swift deleted file mode 100644 index 4ae642f..0000000 --- a/passKitTests/Controllers/PersistenceControllerTest.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// PersistenceControllerTest.swift -// passKitTests -// -// Created by Lysann Tranvouez on 9/3/26. -// Copyright © 2026 Bob Sun. All rights reserved. -// - -import CoreData -import XCTest - -@testable import passKit - -final class PersistenceControllerTest: XCTestCase { - func testModelLoads() { - let controller = PersistenceController.forUnitTests() - let context = controller.viewContext() - - let entityNames = context.persistentStoreCoordinator!.managedObjectModel.entities.map(\.name) - XCTAssertEqual(entityNames, ["PasswordEntity"]) - } - - func testInsertAndFetch() { - let controller = PersistenceController.forUnitTests() - let context = controller.viewContext() - XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) - - PasswordEntity.insert(name: "test", path: "test.gpg", isDir: false, into: context) - try? context.save() - - XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 1) - } - - func testReinitializePersistentStoreClearsData() { - let controller = PersistenceController.forUnitTests() - let context = controller.viewContext() - - PasswordEntity.insert(name: "test1", path: "test1.gpg", isDir: false, into: context) - PasswordEntity.insert(name: "test2", path: "test2.gpg", isDir: false, into: context) - try? context.save() - XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 2) - - controller.reinitializePersistentStore() - - // After reinitialize, old data should be gone - // (reinitializePersistentStore calls initPasswordEntityCoreData with the default repo URL, - // which won't exist in tests, so the result should be an empty store) - let remaining = PasswordEntity.fetchAll(in: context) - XCTAssertEqual(remaining.count, 0) - } - - func testMultipleControllersAreIndependent() { - let controller1 = PersistenceController.forUnitTests() - let controller2 = PersistenceController.forUnitTests() - - let context1 = controller1.viewContext() - let context2 = controller2.viewContext() - - PasswordEntity.insert(name: "only-in-1", path: "only-in-1.gpg", isDir: false, into: context1) - try? context1.save() - - XCTAssertEqual(PasswordEntity.fetchAll(in: context1).count, 1) - XCTAssertEqual(PasswordEntity.fetchAll(in: context2).count, 0) - } - - func testSaveAndLoadFromFile() throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: tempDir) } - let storeURL = tempDir.appendingPathComponent("test.sqlite") - - // Write - let controller1 = PersistenceController(storeURL: storeURL) - let context1 = controller1.viewContext() - PasswordEntity.insert(name: "saved", path: "saved.gpg", isDir: false, into: context1) - PasswordEntity.insert(name: "dir", path: "dir", isDir: true, into: context1) - controller1.save() - - // Load in a fresh controller from the same file - let controller2 = PersistenceController(storeURL: storeURL) - let context2 = controller2.viewContext() - let allEntities = PasswordEntity.fetchAll(in: context2) - - XCTAssertEqual(allEntities.count, 2) - XCTAssertNotNil(allEntities.first { $0.name == "saved" && !$0.isDir }) - XCTAssertNotNil(allEntities.first { $0.name == "dir" && $0.isDir }) - } - - func testSaveError() throws { - // NOTE: save() calls fatalError on Core Data save failures, so error propagation - // cannot be tested without refactoring save() to throw... - } -} diff --git a/passKitTests/CoreData/CoreDataTestCase.swift b/passKitTests/CoreData/CoreDataTestCase.swift index b11ddd1..fa356bf 100644 --- a/passKitTests/CoreData/CoreDataTestCase.swift +++ b/passKitTests/CoreData/CoreDataTestCase.swift @@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - controller = PersistenceController.forUnitTests() + controller = PersistenceController(isUnitTest: true) } override func tearDown() { diff --git a/passKitTests/CoreData/PasswordEntityTest.swift b/passKitTests/CoreData/PasswordEntityTest.swift index ad70f73..6362e2a 100644 --- a/passKitTests/CoreData/PasswordEntityTest.swift +++ b/passKitTests/CoreData/PasswordEntityTest.swift @@ -85,99 +85,4 @@ final class PasswordEntityTest: CoreDataTestCase { XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) } - - // MARK: - initPasswordEntityCoreData tests - - func testInitPasswordEntityCoreDataBuildsTree() throws { - let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: rootDir) } - - // Create directory structure: - // email/ - // work.gpg - // personal.gpg - // social/ - // mastodon.gpg - // toplevel.gpg - // notes.txt (non-.gpg file) - let emailDir = rootDir.appendingPathComponent("email") - let socialDir = rootDir.appendingPathComponent("social") - try FileManager.default.createDirectory(at: emailDir, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: socialDir, withIntermediateDirectories: true) - try Data("test1".utf8).write(to: emailDir.appendingPathComponent("work.gpg")) - try Data("test2".utf8).write(to: emailDir.appendingPathComponent("personal.gpg")) - try Data("test3".utf8).write(to: socialDir.appendingPathComponent("mastodon.gpg")) - try Data("test4".utf8).write(to: rootDir.appendingPathComponent("toplevel.gpg")) - try Data("test5".utf8).write(to: rootDir.appendingPathComponent("notes.txt")) - - let context = controller.viewContext() - PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) - - // Verify total counts - let allEntities = PasswordEntity.fetchAll(in: context) - let files = allEntities.filter { !$0.isDir } - let dirs = allEntities.filter(\.isDir) - XCTAssertEqual(files.count, 5) // 4 .gpg + 1 .txt - XCTAssertEqual(dirs.count, 2) // email, social - - // Verify .gpg extension is stripped - let workEntity = allEntities.first { $0.path == "email/work.gpg" } - XCTAssertNotNil(workEntity) - XCTAssertEqual(workEntity!.name, "work") - - // Verify non-.gpg file keeps its extension - let notesEntity = allEntities.first { $0.path == "notes.txt" } - XCTAssertNotNil(notesEntity) - XCTAssertEqual(notesEntity!.name, "notes.txt") - - // Verify parent-child relationships - let emailEntity = allEntities.first { $0.path == "email" && $0.isDir } - XCTAssertNotNil(emailEntity) - XCTAssertEqual(emailEntity!.children.count, 2) - - // Verify top-level files have no parent (root was deleted) - let toplevelEntity = allEntities.first { $0.path == "toplevel.gpg" } - XCTAssertNotNil(toplevelEntity) - XCTAssertEqual(toplevelEntity!.name, "toplevel") - XCTAssertNil(toplevelEntity!.parent) - } - - func testInitPasswordEntityCoreDataSkipsHiddenFiles() throws { - let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: rootDir) } - - try Data("test".utf8).write(to: rootDir.appendingPathComponent("visible.gpg")) - try Data("test".utf8).write(to: rootDir.appendingPathComponent(".hidden.gpg")) - try Data("test".utf8).write(to: rootDir.appendingPathComponent(".gpg-id")) - try FileManager.default.createDirectory(at: rootDir.appendingPathComponent(".git"), withIntermediateDirectories: true) - try Data("test".utf8).write(to: rootDir.appendingPathComponent(".git/config")) - - let context = controller.viewContext() - PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) - - let allEntities = PasswordEntity.fetchAll(in: context) - XCTAssertEqual(allEntities.count, 1) - XCTAssertEqual(allEntities.first!.name, "visible") - } - - func testInitPasswordEntityCoreDataHandlesEmptyDirectory() throws { - let rootDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: rootDir, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: rootDir) } - - try FileManager.default.createDirectory(at: rootDir.appendingPathComponent("emptydir"), withIntermediateDirectories: true) - - let context = controller.viewContext() - PasswordEntity.initPasswordEntityCoreData(url: rootDir, in: context) - - let allEntities = PasswordEntity.fetchAll(in: context) - let dirs = allEntities.filter(\.isDir) - let files = allEntities.filter { !$0.isDir } - XCTAssertEqual(dirs.count, 1) - XCTAssertEqual(dirs.first!.name, "emptydir") - XCTAssertEqual(dirs.first!.children.count, 0) - XCTAssertEqual(files.count, 0) - } } diff --git a/passKitTests/Crypto/PGPAgentTest.swift b/passKitTests/Crypto/PGPAgentTest.swift index 267742c..8acb2be 100644 --- a/passKitTests/Crypto/PGPAgentTest.swift +++ b/passKitTests/Crypto/PGPAgentTest.swift @@ -31,7 +31,34 @@ final class PGPAgentTest: XCTestCase { super.tearDown() } - // - MARK: Basic encrypt and decrypt tests + private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { + passKit.Defaults.encryptInArmored = encryptInArmored + let encryptedData = try pgpAgent.encrypt(plainData: testData, keyID: keyID) + passKit.Defaults.encryptInArmored = decryptFromArmored + return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase) + } + + func testMultiKeys() throws { + try [ + RSA2048_RSA4096, + ED25519_NISTP384, + ].forEach { testKeyInfo in + keychain.removeAllContent() + try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys) + XCTAssert(pgpAgent.isPrepared) + try pgpAgent.initKeys() + try [ + (true, true), + (true, false), + (false, true), + (false, false), + ].forEach { encryptInArmored, decryptFromArmored in + for id in testKeyInfo.fingerprints { + XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData) + } + } + } + } func testBasicEncryptDecrypt() throws { try [ @@ -48,8 +75,7 @@ final class PGPAgentTest: XCTestCase { try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey) XCTAssert(pgpAgent.isPrepared) try pgpAgent.initKeys() - XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) - XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) + XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) try [ (true, true), (true, false), @@ -135,116 +161,8 @@ final class PGPAgentTest: XCTestCase { XCTAssertEqual(passphraseRequestCalledCount, 3) } - func testMultipleKeysLoaded() throws { - try [ - RSA2048_RSA4096, - ED25519_NISTP384, - ].forEach { testKeyInfo in - keychain.removeAllContent() - try importKeys(testKeyInfo.publicKeys, testKeyInfo.privateKeys) - XCTAssert(pgpAgent.isPrepared) - try pgpAgent.initKeys() - try [ - (true, true), - (true, false), - (false, true), - (false, false), - ].forEach { encryptInArmored, decryptFromArmored in - for id in testKeyInfo.fingerprints { - XCTAssertEqual(try basicEncryptDecrypt(using: pgpAgent, keyID: id, encryptInArmored: encryptInArmored, decryptFromArmored: decryptFromArmored), testData) - } - } - } - } - - func testMultiKeysSelectMatchingPrivateKeyToDecrypt() throws { - keychain.removeAllContent() - try importKeys(RSA2048_RSA4096.publicKeys, RSA2048_RSA4096.privateKeys) - try pgpAgent.initKeys() - try [ - (true, true), - (true, false), - (false, true), - (false, false), - ].forEach { encryptInArmored, decryptFromArmored in - passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [RSA2048.fingerprint]) - passKit.Defaults.encryptInArmored = decryptFromArmored - // Note: not specifying the keyID to decrypt, so that the agent needs to find the matching private key by itself. - let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(decryptedData, testData) - } - } - - // - MARK: Encrypt with multiple keys - - func testEncryptWithMultipleKeys() throws { - keychain.removeAllContent() - // no private key for ED25519 - try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys) - try pgpAgent.initKeys() - - XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PUBLIC).map { $0.lowercased() }.sorted(), (RSA2048_RSA4096.longFingerprints + [ED25519.longFingerprint]).sorted()) - XCTAssertEqual(try pgpAgent.getKeyIDs(type: .PRIVATE).map { $0.lowercased() }.sorted(), RSA2048_RSA4096.longFingerprints.sorted()) - - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: RSA2048_RSA4096.fingerprints + [ED25519.fingerprint]) - - try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in - let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(decryptedData, testData) - } - - XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) { - XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint)) - } - - // load private key for ED25519 - try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey) - try pgpAgent.initKeys() - let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(decryptedData, testData) - } - - func testEncryptWithAllKeys() throws { - // When multiple keys are imported, the agent should be able to encrypt without specifying the keyID. - // It should use all public keys for which we also have private keys, and the encrypted message should be able to be decrypted by any of the private keys. - - keychain.removeAllContent() - // no private key for ED25519 - try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys) - try pgpAgent.initKeys() - - let encryptedData = try pgpAgent.encryptWithAllKeys(plainData: testData) - - try [RSA2048.fingerprint, RSA4096.fingerprint].forEach { keyID in - let decryptedData = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(decryptedData, testData) - } - - XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) { - XCTAssertEqual($0 as! AppError, AppError.pgpPrivateKeyNotFound(keyID: ED25519.fingerprint)) - } - - // load private key for ED25519 - try importKeys(RSA2048_RSA4096.publicKeys | ED25519.publicKey, RSA2048_RSA4096.privateKeys | ED25519.privateKey) - try pgpAgent.initKeys() - - XCTAssertThrowsError(try pgpAgent.decrypt(encryptedData: encryptedData, keyID: ED25519.fingerprint, requestPGPKeyPassphrase: requestPGPKeyPassphrase)) { - XCTAssertEqual($0 as! AppError, AppError.keyExpiredOrIncompatible) - } - } - - // - MARK: Helpers - private func importKeys(_ publicKey: String, _ privateKey: String) throws { try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey) try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey) } - - private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { - passKit.Defaults.encryptInArmored = encryptInArmored - let encryptedData = try pgpAgent.encrypt(plainData: testData, keyIDs: [keyID]) - passKit.Defaults.encryptInArmored = decryptFromArmored - return try pgpAgent.decrypt(encryptedData: encryptedData, keyID: encryptKeyID ?? keyID, requestPGPKeyPassphrase: requestPassphrase) - } } diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD b/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD deleted file mode 100644 index b870d82..0000000 --- a/passKitTests/Fixtures/password-store-empty-dirs.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/config b/passKitTests/Fixtures/password-store-empty-dirs.git/config deleted file mode 100644 index e6da231..0000000 --- a/passKitTests/Fixtures/password-store-empty-dirs.git/config +++ /dev/null @@ -1,6 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true - ignorecase = true - precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/description b/passKitTests/Fixtures/password-store-empty-dirs.git/description deleted file mode 100644 index 498b267..0000000 --- a/passKitTests/Fixtures/password-store-empty-dirs.git/description +++ /dev/null @@ -1 +0,0 @@ -Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude b/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude deleted file mode 100644 index a5196d1..0000000 --- a/passKitTests/Fixtures/password-store-empty-dirs.git/info/exclude +++ /dev/null @@ -1,6 +0,0 @@ -# git ls-files --others --exclude-from=.git/info/exclude -# Lines that start with '#' are comments. -# For a project mostly in C, the following would be a good set of -# exclude patterns (uncomment them if you want to use them): -# *.[oa] -# *~ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea deleted file mode 100644 index 6fa149a..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/44/73d8218dcffe837e18d56d74c240e565461aea and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec deleted file mode 100644 index 1c17f20..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/4c/f9f177c4c015836fca6a31f9c3917e89ae29ec and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 deleted file mode 100644 index 5f9c0af..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a deleted file mode 100644 index 6802d49..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e deleted file mode 100644 index 24279dc..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/d5/64d0bc3dd917926892c55e3706cc116d5b165e and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 deleted file mode 100644 index 7112238..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 b/passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 deleted file mode 100644 index 7052504..0000000 Binary files a/passKitTests/Fixtures/password-store-empty-dirs.git/objects/fb/cbb5819e1c864ef33cfffa179a71387a5d90d0 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs b/passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs deleted file mode 100644 index 025abc4..0000000 --- a/passKitTests/Fixtures/password-store-empty-dirs.git/packed-refs +++ /dev/null @@ -1,2 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -fbcbb5819e1c864ef33cfffa179a71387a5d90d0 refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/HEAD b/passKitTests/Fixtures/password-store-empty.git/HEAD deleted file mode 100644 index b870d82..0000000 --- a/passKitTests/Fixtures/password-store-empty.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/main diff --git a/passKitTests/Fixtures/password-store-empty.git/config b/passKitTests/Fixtures/password-store-empty.git/config deleted file mode 100644 index e6da231..0000000 --- a/passKitTests/Fixtures/password-store-empty.git/config +++ /dev/null @@ -1,6 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true - ignorecase = true - precomposeunicode = true diff --git a/passKitTests/Fixtures/password-store-empty.git/description b/passKitTests/Fixtures/password-store-empty.git/description deleted file mode 100644 index 498b267..0000000 --- a/passKitTests/Fixtures/password-store-empty.git/description +++ /dev/null @@ -1 +0,0 @@ -Unnamed repository; edit this file 'description' to name the repository. diff --git a/passKitTests/Fixtures/password-store-empty.git/info/exclude b/passKitTests/Fixtures/password-store-empty.git/info/exclude deleted file mode 100644 index a5196d1..0000000 --- a/passKitTests/Fixtures/password-store-empty.git/info/exclude +++ /dev/null @@ -1,6 +0,0 @@ -# git ls-files --others --exclude-from=.git/info/exclude -# Lines that start with '#' are comments. -# For a project mostly in C, the following would be a good set of -# exclude patterns (uncomment them if you want to use them): -# *.[oa] -# *~ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 deleted file mode 100644 index adf6411..0000000 Binary files a/passKitTests/Fixtures/password-store-empty.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 b/passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 deleted file mode 100644 index c1ae0b0..0000000 Binary files a/passKitTests/Fixtures/password-store-empty.git/objects/4e/b23a2d659dcaa6fbc01ada57aed6d1fbeb0520 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 b/passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 deleted file mode 100644 index 5f9c0af..0000000 Binary files a/passKitTests/Fixtures/password-store-empty.git/objects/50/96ac11d1376ea9b22ddedac1130f45ec618d11 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a b/passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a deleted file mode 100644 index 7dbbc42..0000000 Binary files a/passKitTests/Fixtures/password-store-empty.git/objects/ae/a863facd4acba3b4862e3f42847da1000e486a and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd b/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd deleted file mode 100644 index 3e561dd..0000000 --- a/passKitTests/Fixtures/password-store-empty.git/objects/f0/95bb4897e4cd58faadfe4d4f678fb697be3ffd +++ /dev/null @@ -1,3 +0,0 @@ -xM -0@a9 \zi:@BzzK}eN*3Sv48J=Eo؎D҈k"EMnKNRy,_pr_9p;qETھ -&rɒi@ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-empty.git/packed-refs b/passKitTests/Fixtures/password-store-empty.git/packed-refs deleted file mode 100644 index 432aeb4..0000000 --- a/passKitTests/Fixtures/password-store-empty.git/packed-refs +++ /dev/null @@ -1,2 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD deleted file mode 100644 index ef06926..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/FETCH_HEAD +++ /dev/null @@ -1 +0,0 @@ -925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD b/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD deleted file mode 100644 index cb089cd..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/HEAD +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/master diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/config b/passKitTests/Fixtures/password-store-with-gpgid.git/config deleted file mode 100644 index 876f087..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/config +++ /dev/null @@ -1,9 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = true - ignorecase = true - precomposeunicode = true -[remote "origin"] - url = https://github.com/mssun/passforios-password-store.git - fetch = +refs/heads/*:refs/remotes/origin/* diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/description b/passKitTests/Fixtures/password-store-with-gpgid.git/description deleted file mode 100644 index 5536ff7..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/description +++ /dev/null @@ -1 +0,0 @@ -Example password store repository for passforios tests with .gpg-id files. diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude b/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude deleted file mode 100644 index a5196d1..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/info/exclude +++ /dev/null @@ -1,6 +0,0 @@ -# git ls-files --others --exclude-from=.git/info/exclude -# Lines that start with '#' are comments. -# For a project mostly in C, the following would be a good set of -# exclude patterns (uncomment them if you want to use them): -# *.[oa] -# *~ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 deleted file mode 100644 index d798c2c..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/23/44f8116ab49ad30dd96d92e306a2b28839ee71 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 deleted file mode 100644 index 69995d0..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/34/22e35e46f42b12fdca050c9bc424028d0e89c8 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 deleted file mode 100644 index ea752fb..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/8b/08bf485e19720eae26df62ddceefbb8b4d8247 and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 deleted file mode 100644 index 61d0371..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/bb/1f455127743a2e202840fc39201937a2457ed2 +++ /dev/null @@ -1 +0,0 @@ -x1 @k^BI(9Bj]lEUMC{lD+=rKd8.4uy: n痈Ky1F \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 deleted file mode 100644 index 89b191d..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/c7/c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 +++ /dev/null @@ -1,3 +0,0 @@ -xAj0s+bF#Y < |@m]Hry}B>C_ -~O S+"@yڅh}`ޱ'1EGEP$7KEiyh8f -G[g 9g {;^oH'a0d{D ϨlUKS5T B-BfH’z9RS9v qkcJ \ No newline at end of file diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx deleted file mode 100644 index 7efd056..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.idx and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack b/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack deleted file mode 100644 index 038de79..0000000 Binary files a/passKitTests/Fixtures/password-store-with-gpgid.git/objects/pack/pack-6a8dbb253e7642cc425de97363624aab04882615.pack and /dev/null differ diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs b/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs deleted file mode 100644 index 5b72267..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/packed-refs +++ /dev/null @@ -1,2 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master deleted file mode 100644 index 2b6288a..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/heads/master +++ /dev/null @@ -1 +0,0 @@ -c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3 diff --git a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master b/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master deleted file mode 100644 index 7d10008..0000000 --- a/passKitTests/Fixtures/password-store-with-gpgid.git/refs/remotes/origin/master +++ /dev/null @@ -1 +0,0 @@ -925eb0f6b19282b5f10dfe008e0062b4be6dd41a diff --git a/passKitTests/Helpers/AppKeychainTest.swift b/passKitTests/Helpers/AppKeychainTest.swift deleted file mode 100644 index 9b420fc..0000000 --- a/passKitTests/Helpers/AppKeychainTest.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// AppKeychainTest.swift -// passKitTests -// -// Created by Lysann Tranvouez on 9/3/26. -// Copyright © 2026 Bob Sun. All rights reserved. -// - -import XCTest - -@testable import passKit - -final class AppKeychainTest: XCTestCase { - private let keychain = AppKeychain.shared - private let testPrefix = "test.AppKeychainTest." - - override func tearDown() { - super.tearDown() - keychain.removeAllContent(withPrefix: testPrefix) - } - - private func key(_ name: String) -> String { - "\(testPrefix)\(name)" - } - - // MARK: - Basic round-trip - - func testAddAndGet() { - keychain.add(string: "hello", for: key("addGet")) - - XCTAssertEqual(keychain.get(for: key("addGet")), "hello") - } - - func testGetMissingKeyReturnsNil() { - XCTAssertNil(keychain.get(for: key("nonexistent"))) - } - - func testOverwriteValue() { - keychain.add(string: "first", for: key("overwrite")) - keychain.add(string: "second", for: key("overwrite")) - - XCTAssertEqual(keychain.get(for: key("overwrite")), "second") - } - - func testAddNilRemovesValue() { - keychain.add(string: "value", for: key("addNil")) - keychain.add(string: nil, for: key("addNil")) - - XCTAssertNil(keychain.get(for: key("addNil"))) - XCTAssertFalse(keychain.contains(key: key("addNil"))) - } - - // MARK: - contains - - func testContainsReturnsTrueForExistingKey() { - keychain.add(string: "value", for: key("exists")) - - XCTAssertTrue(keychain.contains(key: key("exists"))) - } - - func testContainsReturnsFalseForMissingKey() { - XCTAssertFalse(keychain.contains(key: key("missing"))) - } - - // MARK: - removeContent - - func testRemoveContent() { - keychain.add(string: "value", for: key("remove")) - keychain.removeContent(for: key("remove")) - - XCTAssertNil(keychain.get(for: key("remove"))) - XCTAssertFalse(keychain.contains(key: key("remove"))) - } - - func testRemoveContentForMissingKeyDoesNotThrow() { - keychain.removeContent(for: key("neverExisted")) - // No assertion needed — just verifying it doesn't crash - } - - // MARK: - removeAllContent(withPrefix:) - - func testRemoveAllContentWithPrefix() { - keychain.add(string: "1", for: key("prefixA.one")) - keychain.add(string: "2", for: key("prefixA.two")) - keychain.add(string: "3", for: key("prefixB.one")) - - keychain.removeAllContent(withPrefix: key("prefixA")) - - XCTAssertNil(keychain.get(for: key("prefixA.one"))) - XCTAssertNil(keychain.get(for: key("prefixA.two"))) - XCTAssertEqual(keychain.get(for: key("prefixB.one")), "3") - } - - func testRemoveAllContentWithPrefixNoMatches() { - keychain.add(string: "value", for: key("survivor")) - - keychain.removeAllContent(withPrefix: key("noMatch")) - - XCTAssertEqual(keychain.get(for: key("survivor")), "value") - } -} diff --git a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift b/passKitTests/LowLevel/PGPAgentLowLevelTests.swift deleted file mode 100644 index a1e6e99..0000000 --- a/passKitTests/LowLevel/PGPAgentLowLevelTests.swift +++ /dev/null @@ -1,414 +0,0 @@ -// -// PGPAgentLowLevelTests.swift -// passKitTests -// -// Detailed unit tests tracking the exact API call behavior of PGPAgent.decrypt. -// Uses MockPGPInterface to verify what arguments are passed to the underlying -// PGPInterface methods, and how passphrase resolution interacts with the keystore -// and the requestPGPKeyPassphrase callback. -// - -import XCTest - -@testable import passKit - -final class PGPAgentLowLevelTests: XCTestCase { - private var keychain: DictBasedKeychain! - private var mockPGP: MockPGPInterface! - private var agent: PGPAgent! - - private let testEncryptedData = Data("encrypted-payload".utf8) - private let testDecryptedData = Data("decrypted-payload".utf8) - - /// Tracks all calls to requestPGPKeyPassphrase closures created via `passphraseCallback(_:)`. - private var passphraseRequests: [String] = [] - - /// Creates a requestPGPKeyPassphrase closure that records the keyID it's called with - /// into `passphraseRequests` and returns `response`. - private func passphraseCallback(_ response: String) -> (String) -> String { - { [self] keyID in - passphraseRequests.append(keyID) - return response - } - } - - override func setUp() { - super.setUp() - - keychain = DictBasedKeychain() - // Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock. - keychain.add(string: "dummy", for: Globals.pgpKeyPassphrase) - - mockPGP = MockPGPInterface() - // some defaults - mockPGP.decryptResult = testDecryptedData - mockPGP.encryptResult = Data("mock-encrypted".utf8) - - passphraseRequests = [] - - agent = PGPAgent(keyStore: keychain, pgpInterface: mockPGP) - } - - override func tearDown() { - keychain.removeAllContent() - super.tearDown() - } - - // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Key Resolution - - /// When the private key is found, decrypt is called with the provided keyID. - func testDecryptWithKeyID_keyFound_usesProvidedKeyID() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - - let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass")) - - XCTAssertEqual(result, testDecryptedData) - XCTAssertEqual(mockPGP.decryptCalls.count, 1) - XCTAssertEqual(mockPGP.decryptCalls[0].keyID, longFingerprint) - XCTAssertEqual(mockPGP.decryptCalls[0].encryptedData, testEncryptedData) - } - - func testDecryptWithKeyID_keyNotFound_throws() { - mockPGP.privateKeyIDs = [] - - XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in - XCTAssertEqual(error as? AppError, AppError.pgpPrivateKeyNotFound(keyID: "UNKNOWN")) - } - // pgpInterface.decrypt should NOT have been called - XCTAssertEqual(mockPGP.decryptCalls.count, 0) - } - - /// containsPrivateKey is called with the provided keyID to check membership. - func testDecryptWithKeyID_checksContainsPrivateKey() throws { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) - - XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) - } - - // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Passphrase Resolution - - /// On first decrypt (latestDecryptStatus=true), the passphrase is looked up from keystore first. - /// If found in keystore, requestPGPKeyPassphrase is NOT called. - func testDecryptWithKeyID_firstCall_passphraseFromKeystore() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - mockPGP.selectedKeyForPassphrase = longFingerprint - keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("requested-passphrase")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["stored-passphrase"]) - XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when passphrase is in keystore") - } - - /// On first decrypt, if keystore doesn't have the passphrase, requestPGPKeyPassphrase is called. - /// The keyID passed to requestPGPKeyPassphrase is the (possibly resolved) keyID. - func testDecryptWithKeyID_firstCall_passphraseFromRequest() throws { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - mockPGP.selectedKeyForPassphrase = shortID - // No passphrase in keystore for this key. - XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID))) - XCTAssertFalse(keychain.contains(key: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint))) - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("my-passphrase")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["my-passphrase"]) - XCTAssertEqual(passphraseRequests, [shortID]) - } - - /// After a failed decrypt (latestDecryptStatus=false), requestPGPKeyPassphrase is ALWAYS called, - /// even if the keystore has a cached passphrase. - func testDecrypt_afterFailure_alwaysRequestsPassphrase() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - keychain.add(string: "stored-passphrase", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - - // First call: force a failure by making decrypt throw. - mockPGP.decryptError = AppError.wrongPassphrase - XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad"))) - - // Now latestDecryptStatus=false. Second call should always request. - mockPGP.decryptError = nil - mockPGP.decryptCalls.removeAll() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = longFingerprint - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh-passphrase")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh-passphrase"]) - XCTAssertEqual(passphraseRequests, [longFingerprint], "After failure, passphrase should always be requested") - } - - /// After a successful decrypt, the next call uses keystore first (latestDecryptStatus=true). - func testDecrypt_afterSuccess_usesKeystoreFirst() throws { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - - // First call succeeds. - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass1")) - - // Store a passphrase in keystore under the short ID (matching what PGPAgent used for lookup). - keychain.add(string: "pass1", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) - - mockPGP.decryptCalls.removeAll() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = shortID - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("ignored-passphrase")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["pass1"]) - XCTAssertEqual(passphraseRequests, []) - } - - // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - Return Values & Error Propagation - - /// When pgpInterface.decrypt returns nil, agent.decrypt returns nil. - func testDecrypt_interfaceReturnsNil_returnsNil() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - mockPGP.decryptResult = nil - - let result = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("pass")) - - XCTAssertNil(result) - } - - /// When pgpInterface.decrypt returns nil, latestDecryptStatus stays false - /// (next call will always request passphrase). - func testDecrypt_interfaceReturnsNil_statusStaysFalse() throws { - let shortID = "d862027e" - let longFingerprint = "787eae1a5fa3e749aa34cc6aa0645ebed862027e" - mockPGP.privateKeyIDs = [longFingerprint] - mockPGP.decryptResult = nil - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) - - // Second call - should always request (latestDecryptStatus=false because nil return doesn't set it to true). - keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) - keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - mockPGP.decryptResult = testDecryptedData - mockPGP.decryptCalls.removeAll() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = longFingerprint - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) - XCTAssertEqual(passphraseRequests, [longFingerprint], "After nil return, passphrase should always be requested") - } - - /// When pgpInterface.decrypt throws, the error propagates and latestDecryptStatus stays false. - func testDecrypt_interfaceThrows_propagatesError() throws { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - mockPGP.decryptError = AppError.wrongPassphrase - mockPGP.selectedKeyForPassphrase = longFingerprint - - XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in - XCTAssertEqual(error as? AppError, AppError.wrongPassphrase) - } - XCTAssertEqual(passphraseRequests, [longFingerprint]) - - // Verify latestDecryptStatus stayed false: next call should always request passphrase, - // even though the keystore has one cached. - keychain.add(string: "cached", for: AppKeychain.getPGPKeyPassphraseKey(keyID: shortID)) - keychain.add(string: "cached-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - mockPGP.decryptError = nil - mockPGP.decryptCalls.removeAll() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = longFingerprint - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("fresh")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["fresh"]) - XCTAssertEqual(passphraseRequests, [longFingerprint], "After throw, passphrase should always be requested (latestDecryptStatus=false)") - } - - /// After successful decrypt, latestDecryptStatus is true. - func testDecrypt_success_setsStatusTrue() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - - // Force latestDecryptStatus to false first. - mockPGP.decryptError = AppError.wrongPassphrase - _ = try? agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("bad")) - mockPGP.decryptError = nil - mockPGP.decryptCalls.removeAll() - passphraseRequests.removeAll() - - // Now succeed. - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("good")) - - // Third call: latestDecryptStatus=true, so should try keystore first. - keychain.add(string: "good", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - mockPGP.decryptCalls.removeAll() - mockPGP.resolvedPassphrases.removeAll() - mockPGP.selectedKeyForPassphrase = longFingerprint - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("should-not-use")) - - XCTAssertEqual(mockPGP.resolvedPassphrases, ["good"]) - XCTAssertEqual(passphraseRequests, [], "After success, should try keystore first") - } - - // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - checkAndInit behavior - - /// checkAndInit re-initializes if pgpKeyPassphrase is missing from keystore. - /// Since we're using a mock as pgpInterface, initKeys would overwrite it; verify the precondition holds. - func testDecrypt_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws { - // Remove the pgpKeyPassphrase sentinel, which will trigger checkAndInit -> initKeys. - keychain.removeContent(for: Globals.pgpKeyPassphrase) - // initKeys needs real PGP keys, which we don't have. It should throw keyImport. - XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "a1024dae", requestPGPKeyPassphrase: passphraseCallback("pass"))) { error in - XCTAssertEqual(error as? AppError, AppError.keyImport) - } - XCTAssertEqual(passphraseRequests, [], "requestPGPKeyPassphrase should not be called when checkAndInit fails") - } - - // MARK: - decrypt(encryptedData:keyID:requestPGPKeyPassphrase:) - nil keyID - - /// The no-keyID overload passes nil as keyID to pgpInterface.decrypt - func testDecryptNoKeyID_passesNilKeyIDToInterface() throws { - let result = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) - - XCTAssertEqual(result, testDecryptedData) - XCTAssertEqual(mockPGP.decryptCalls.count, 1) - XCTAssertNil(mockPGP.decryptCalls[0].keyID) - } - - /// The no-keyID overload doesn't check containsPrivateKey. - func testDecryptNoKeyID_doesNotCheckPrivateKey() throws { - _ = try agent.decrypt(encryptedData: testEncryptedData, requestPGPKeyPassphrase: passphraseCallback("pass")) - - XCTAssertEqual(mockPGP.containsPrivateKeyCalls.count, 0) - } - - // MARK: - Key resolution error vs decrypt status ordering - - /// When pgpPrivateKeyNotFound is thrown, latestDecryptStatus is NOT changed because the error occurs BEFORE the status update. - func testDecryptWithKeyID_keyNotFound_doesNotChangeDecryptStatus() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [] - - // This throws pgpPrivateKeyNotFound without changing latestDecryptStatus. - XCTAssertThrowsError(try agent.decrypt(encryptedData: testEncryptedData, keyID: "UNKNOWN", requestPGPKeyPassphrase: passphraseCallback("pass"))) - - // latestDecryptStatus should still be true (initial value). - // Next call should try keystore first. - mockPGP.privateKeyIDs = [longFingerprint] - keychain.add(string: "cached-pass", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - mockPGP.selectedKeyForPassphrase = longFingerprint - passphraseRequests.removeAll() - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: longFingerprint, requestPGPKeyPassphrase: passphraseCallback("fresh")) - - XCTAssertEqual(passphraseRequests, [], "After pgpPrivateKeyNotFound, latestDecryptStatus should be unchanged (still true)") - XCTAssertEqual(mockPGP.resolvedPassphrases, ["cached-pass"]) - } - - // MARK: - Short vs long key ID behavior - - /// When caller passes a short ID and containsPrivateKey matches it (via suffix), the short ID - /// is forwarded to pgpInterface.decrypt. - func testDecryptWithKeyID_shortIDRecognized_shortIDFlowsThrough() throws { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("pass")) - - XCTAssertEqual(mockPGP.containsPrivateKeyCalls, [shortID]) - XCTAssertEqual(mockPGP.decryptCalls[0].keyID, shortID) - } - - /// Passphrase stored under long fingerprint is NOT found when the short ID is used for lookup - func testDecryptWithKeyID_shortIDRecognized_passphraseStoredUnderLongID_missesKeystore() throws { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.privateKeyIDs = [longFingerprint] - mockPGP.selectedKeyForPassphrase = shortID - - // Store passphrase under the LONG fingerprint. - keychain.add(string: "stored-under-long", for: AppKeychain.getPGPKeyPassphraseKey(keyID: longFingerprint)) - - _ = try agent.decrypt(encryptedData: testEncryptedData, keyID: shortID, requestPGPKeyPassphrase: passphraseCallback("from-request")) - - // Backend requests passphrase with short ID — keystore lookup misses, falls through to request. - XCTAssertEqual(mockPGP.resolvedPassphrases, ["from-request"]) - XCTAssertEqual(passphraseRequests, [shortID]) - } - - // MARK: - Encrypt passthrough tests (for completeness of mock interaction) - - func testEncryptWithKeyIDs_passesThrough() throws { - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.publicKeyIDs = [longFingerprint] - - let result = try agent.encrypt(plainData: testDecryptedData, keyIDs: [longFingerprint]) - - XCTAssertEqual(result, mockPGP.encryptResult) - XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 1) - XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].keyIDs, [longFingerprint]) - XCTAssertEqual(mockPGP.encryptMultiKeyCalls[0].plainData, testDecryptedData) - } - - /// encrypt propagates errors from interface. - func testEncryptWithKeyIDs_interfaceThrows_propagatesError() { - let shortID = "a1024dae" - let longFingerprint = "4712286271220db299883ea7062e678da1024dae" - mockPGP.publicKeyIDs = [longFingerprint] - mockPGP.encryptError = AppError.encryption - - XCTAssertThrowsError(try agent.encrypt(plainData: testDecryptedData, keyIDs: [shortID])) { error in - XCTAssertEqual(error as? AppError, AppError.encryption) - } - } - - // MARK: - encryptWithAllKeys - - /// encryptWithAllKeys delegates to pgpInterface.encryptWithAllKeys. - func testEncryptWithAllKeys_callsInterface() throws { - mockPGP.encryptResult = Data("all-keys-encrypted".utf8) - - let result = try agent.encryptWithAllKeys(plainData: testDecryptedData) - - XCTAssertEqual(result, Data("all-keys-encrypted".utf8)) - XCTAssertEqual(mockPGP.encryptWithAllKeysCalls.count, 1) - XCTAssertEqual(mockPGP.encryptWithAllKeysCalls[0].plainData, testDecryptedData) - // Does not call containsPublicKey or the single/multi-key encrypt methods. - XCTAssertEqual(mockPGP.containsPublicKeyCalls.count, 0) - XCTAssertEqual(mockPGP.encryptMultiKeyCalls.count, 0) - } - - /// encryptWithAllKeys propagates errors from interface. - func testEncryptWithAllKeys_interfaceThrows_propagatesError() { - mockPGP.encryptError = AppError.encryption - - XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in - XCTAssertEqual(error as? AppError, AppError.encryption) - } - } - - /// encryptWithAllKeys throws keyImport when checkAndInit triggers initKeys without PGP keys. - func testEncryptWithAllKeys_checkAndInit_requiresPGPKeyPassphraseInKeystore() throws { - keychain.removeContent(for: Globals.pgpKeyPassphrase) - - XCTAssertThrowsError(try agent.encryptWithAllKeys(plainData: testDecryptedData)) { error in - XCTAssertEqual(error as? AppError, AppError.keyImport) - } - } -} diff --git a/passKitTests/Mocks/MockPGPInterface.swift b/passKitTests/Mocks/MockPGPInterface.swift deleted file mode 100644 index 9593669..0000000 --- a/passKitTests/Mocks/MockPGPInterface.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// MockPGPInterface.swift -// passKitTests -// - -import Foundation -@testable import passKit - -class MockPGPInterface: PGPInterface { - // MARK: - Configuration - - var publicKeyIDs: Set = [] - var privateKeyIDs: Set = [] - - var decryptResult: Data? - var decryptError: Error? - var encryptResult = Data() - var encryptError: Error? - - /// When set, the mock calls `passPhraseForKey` with this key ID during `decrypt`, - /// simulating the PGP backend selecting a key and requesting its passphrase. - var selectedKeyForPassphrase: String? - - // MARK: - Call tracking - - struct DecryptCall { - let encryptedData: Data - let keyID: String? - let passPhraseForKey: (String) -> String - } - - struct EncryptCall { - let plainData: Data - let keyID: String? - } - - struct EncryptMultiKeyCall { - let plainData: Data - let keyIDs: [String] - } - - struct EncryptWithAllKeysCall { - let plainData: Data - } - - var decryptCalls: [DecryptCall] = [] - var resolvedPassphrases: [String] = [] - var encryptMultiKeyCalls: [EncryptMultiKeyCall] = [] - var encryptWithAllKeysCalls: [EncryptWithAllKeysCall] = [] - var containsPublicKeyCalls: [String] = [] - var containsPrivateKeyCalls: [String] = [] - - // MARK: - PGPInterface - - func decrypt(encryptedData: Data, keyIDHint keyID: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? { - decryptCalls.append(DecryptCall(encryptedData: encryptedData, keyID: keyID, passPhraseForKey: passPhraseForKey)) - if let selectedKey = selectedKeyForPassphrase { - resolvedPassphrases.append(passPhraseForKey(selectedKey)) - } - if let error = decryptError { - throw error - } - return decryptResult - } - - func encryptWithAllKeys(plainData: Data) throws -> Data { - encryptWithAllKeysCalls.append(EncryptWithAllKeysCall(plainData: plainData)) - if let error = encryptError { - throw error - } - return encryptResult - } - - func encrypt(plainData: Data, keyIDs: [String]) throws -> Data { - encryptMultiKeyCalls.append(EncryptMultiKeyCall(plainData: plainData, keyIDs: keyIDs)) - if let error = encryptError { - throw error - } - return encryptResult - } - - func containsPublicKey(with keyID: String) -> Bool { - containsPublicKeyCalls.append(keyID) - return publicKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) } - } - - func containsPrivateKey(with keyID: String) -> Bool { - containsPrivateKeyCalls.append(keyID) - return privateKeyIDs.contains { $0.hasSuffix(keyID.lowercased()) } - } - - func getKeyIDs(type _: PGPKey) -> [String] { - // currently irrelevant for the tests - [] - } - - func getShortKeyIDs(type _: PGPKey) -> [String] { - // currently irrelevant for the tests - [] - } -} diff --git a/passKitTests/Models/PasswordStoreTest.swift b/passKitTests/Models/PasswordStoreTest.swift index 52b683c..b65bf95 100644 --- a/passKitTests/Models/PasswordStoreTest.swift +++ b/passKitTests/Models/PasswordStoreTest.swift @@ -13,622 +13,47 @@ import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { - private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") + private let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! - private var keyStore: KeyStore! = nil - private var pgpAgent: PGPAgent! = nil - private var passwordStore: PasswordStore! = nil + func testCloneAndDecryptMultiKeys() throws { + let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") - override func setUp() { - super.setUp() - - keyStore = DictBasedKeychain() - pgpAgent = PGPAgent(keyStore: keyStore) - passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent) - } - - private func setUpMockedPGPInterface() -> MockPGPInterface { - let mockPGPInterface = MockPGPInterface() - keyStore = DictBasedKeychain() - pgpAgent = PGPAgent(keyStore: keyStore, pgpInterface: mockPGPInterface) - passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent) - - // Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock. - keyStore.add(string: "dummy", for: Globals.pgpKeyPassphrase) - - return mockPGPInterface - } - - override func tearDown() { - passwordStore.erase() - passwordStore = nil - pgpAgent = nil - keyStore = nil - - Defaults.removeAll() - - super.tearDown() - } - - func testInitPasswordEntityCoreData() throws { - try cloneRepository(.withGPGID) - - XCTAssertEqual(passwordStore.numberOfPasswords, 4) - XCTAssertEqual(passwordStore.numberOfCommits, 17) - XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) - - let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") - XCTAssertEqual(entity!.path, "personal/github.com.gpg") - XCTAssertEqual(entity!.name, "github.com") - XCTAssertTrue(entity!.isSynced) - XCTAssertEqual(entity!.parent!.name, "personal") - - XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "family/amazon.com.gpg")) - XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) - XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "shared/github.com.gpg")) - - let dirEntity = passwordStore.fetchPasswordEntity(with: "shared") - XCTAssertNotNil(dirEntity) - XCTAssertTrue(dirEntity!.isDir) - XCTAssertEqual(dirEntity!.name, "shared") - XCTAssertEqual(dirEntity!.children.count, 1) - } - - func testEraseStoreData() throws { - try cloneRepository(.withGPGID) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) - XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) - XCTAssertNotNil(passwordStore.gitRepository) - - expectation(forNotification: .passwordStoreUpdated, object: nil) - expectation(forNotification: .passwordStoreErased, object: nil) - passwordStore.eraseStoreData() - - XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) - XCTAssertEqual(passwordStore.numberOfPasswords, 0) - XCTAssertNil(passwordStore.gitRepository) - waitForExpectations(timeout: 1, handler: nil) - } - - func testErase() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() - Defaults.gitSignatureName = "Test User" - PasscodeLock.shared.save(passcode: "1234") - - XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) - XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) - XCTAssertEqual(Defaults.gitSignatureName, "Test User") - XCTAssertTrue(PasscodeLock.shared.hasPasscode) - XCTAssertTrue(pgpAgent.isInitialized()) - - expectation(forNotification: .passwordStoreUpdated, object: nil) - expectation(forNotification: .passwordStoreErased, object: nil) - passwordStore.erase() - - XCTAssertEqual(passwordStore.numberOfPasswords, 0) - XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) - XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) - XCTAssertFalse(PasscodeLock.shared.hasPasscode) - XCTAssertFalse(pgpAgent.isInitialized()) - waitForExpectations(timeout: 1, handler: nil) - } - - func testFetchPasswordEntityCoreDataByParent() throws { - try cloneRepository(.withGPGID) - - let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) - XCTAssertGreaterThan(rootChildren.count, 0) - rootChildren.forEach { entity in - XCTAssertTrue(entity.isDir) - } - - let personalDir = passwordStore.fetchPasswordEntity(with: "personal") - let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir) - XCTAssertEqual(personalChildren.count, 1) - XCTAssertEqual(personalChildren.first?.name, "github.com") - } - - func testFetchPasswordEntityCoreDataWithDir() throws { - try cloneRepository(.withGPGID) - - let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) - XCTAssertEqual(allPasswords.count, 4) - allPasswords.forEach { entity in - XCTAssertFalse(entity.isDir) - } - } - - func testAddPassword() throws { - try cloneRepository(.empty) - try importSinglePGPKey() - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - - let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") - let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") - let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") - let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") - - for password in [password1, password2, password3, password4] { - expectation(forNotification: .passwordStoreUpdated, object: nil) - - let savedEntity = try passwordStore.add(password: password) - - XCTAssertEqual(savedEntity!.name, password.name) - waitForExpectations(timeout: 1, handler: nil) - } - - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test1.gpg").path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test2.gpg").path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder").path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder/test3.gpg").path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test4.gpg").path)) - - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 4) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) - } - - func testAddAndDecryptRoundTrip() throws { - try cloneRepository(.empty) - try importSinglePGPKey() - - let password = Password(name: "test", path: "test.gpg", plainText: "foobar") - let savedEntity = try passwordStore.add(password: password) - - let decryptedPassword = try passwordStore.decrypt(passwordEntity: savedEntity!, requestPGPKeyPassphrase: requestPGPKeyPassphrase) - XCTAssertEqual(decryptedPassword.plainText, "foobar") - } - - func testDeletePassword() throws { - try cloneRepository(.withGPGID) - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - - expectation(forNotification: .passwordStoreUpdated, object: nil) - - let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") - try passwordStore.delete(passwordEntity: entity!) - - XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) - XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) - XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) - waitForExpectations(timeout: 1, handler: nil) - } - - func testDeletePasswordKeepsFileSystemFolderIfNotEmpty() throws { - try cloneRepository(.withGPGID) - - // /work contains .gpg-id in addition to a password file - let entity = passwordStore.fetchPasswordEntity(with: "work/github.com.gpg") - try passwordStore.delete(passwordEntity: entity!) - - XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("work/github.com.gpg").path)) - XCTAssertNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) - XCTAssertNil(passwordStore.fetchPasswordEntity(with: "work")) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("work/.gpg-id").path)) - } - - func testDeleteEmptyDirectory() throws { - try cloneRepository(.emptyDirs) - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - - expectation(forNotification: .passwordStoreUpdated, object: nil) - - // Note: the directory isn't truely empty since Git doesn't track empty directories, - // but it should be treated as empty by the app since it contains only hidden files - let entityToDelete = passwordStore.fetchPasswordEntity(with: "empty-dir") - XCTAssertNotNil(entityToDelete) - try passwordStore.delete(passwordEntity: entityToDelete!) - - XCTAssertNil(passwordStore.fetchPasswordEntity(with: "empty-dir")) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("empty-dir/.gitkeep").path)) - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) - waitForExpectations(timeout: 1, handler: nil) - } - - func testDeleteNonEmptyDirectoryFails() throws { - try cloneRepository(.withGPGID) - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - - expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true - - let entity = passwordStore.fetchPasswordEntity(with: "personal") - XCTAssertThrowsError(try passwordStore.delete(passwordEntity: entity!)) { error in - XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") - XCTAssertEqual(error as? AppError, .cannotDeleteNonEmptyDirectory) - } - - XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) - waitForExpectations(timeout: 0.1, handler: nil) - } - - func testEditPasswordValue() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! - - expectation(forNotification: .passwordStoreUpdated, object: nil) - - let editedPassword = Password(name: entity.name, path: entity.path, plainText: "editedpassword") - editedPassword.changed = PasswordChange.content.rawValue - let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) - - XCTAssertNotNil(editedEntity) - XCTAssertEqual(editedEntity!.name, "github.com") - XCTAssertFalse(editedEntity!.isSynced) - XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) - waitForExpectations(timeout: 1, handler: nil) - } - - func testMovePassword() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! - - expectation(forNotification: .passwordStoreUpdated, object: nil) - - let editedPassword = Password(name: "new name", path: "new name.gpg", plainText: "passwordforpersonal\n") - editedPassword.changed = PasswordChange.path.rawValue - let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) - - XCTAssertEqual(editedEntity!.name, "new name") - XCTAssertFalse(editedEntity!.isSynced) - XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") - XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) - waitForExpectations(timeout: 1, handler: nil) - } - - func testEditDirectoryFails() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() - let numCommitsBefore = passwordStore.numberOfCommits! - - let directoryEntity = passwordStore.fetchPasswordEntity(with: "personal")! - let editedPassword = Password(name: "new name", path: "new name", plainText: "") - editedPassword.changed = PasswordChange.path.rawValue - XCTAssertThrowsError(try passwordStore.edit(passwordEntity: directoryEntity, password: editedPassword)) { error in - XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") - XCTAssertEqual(error as? AppError, .other(message: "Cannot edit a directory")) - } - - XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal")) - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) - } - - func testReset() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() - let numCommitsBefore = passwordStore.numberOfCommits! - let numLocalCommitsBefore = passwordStore.numberOfLocalCommits - - _ = try passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) - try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) - - expectation(forNotification: .passwordStoreUpdated, object: nil) - let numDroppedCommits = try passwordStore.reset() - - XCTAssertEqual(numDroppedCommits, 2) - XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test.gpg").path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) - XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) - XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) - waitForExpectations(timeout: 1, handler: nil) - } - - // MARK: - Find .gpg-id - - func testFindGPGIDFile() throws { - try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8))) - - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("foo/bar/baz"), withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path, contents: Data("under foo".utf8))) - - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/"), withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8))) - - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "/")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/..")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) - - // there is a _drectory_ called .gpg-id in here - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id/hey")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path)) - - // "foo/bar/../../baz" resolves to "baz" which has no .gpg-id, so should find root's. - // Without path resolution, the walk ["foo","bar","..","..","baz"] → remove "baz" → remove ".." → - // "foo/bar/.." → remove ".." → "foo/bar" → finds foo/.gpg-id (wrong). - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("baz"), withIntermediateDirectories: true) - XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/../../baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - } - - func testMissingGPGIDFile() throws { - XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path)) - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true) - - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "subdir")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "missing")) - } - - func testFindGPGIDFileStopsAtRoot() throws { - // Place a .gpg-id file ABOVE the store root, this should not be found - let parentDir = localRepoURL.deletingLastPathComponent() - let escapedGPGIDURL = parentDir.appendingPathComponent(".gpg-id") - XCTAssertTrue(FileManager.default.createFile(atPath: escapedGPGIDURL.path, contents: Data("ESCAPED_KEY".utf8))) - defer { try? FileManager.default.removeItem(at: escapedGPGIDURL) } - - // Store has no .gpg-id at all - try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("sub/deep"), withIntermediateDirectories: true) - // Direct paths, should not find the escaped .gpg-id since it's outside the store root - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep")) - - // Path traversal attempts via ".." - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "../..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/../..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../..")) - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../../../../etc")) - - // Symlink escape: create a symlink inside the store pointing outside - let evilDir = parentDir.appendingPathComponent("evil") - try FileManager.default.createDirectory(at: evilDir, withIntermediateDirectories: true) - XCTAssertTrue(FileManager.default.createFile(atPath: evilDir.appendingPathComponent(".gpg-id").path, contents: Data("EVIL_KEY".utf8))) - defer { try? FileManager.default.removeItem(at: evilDir) } - try FileManager.default.createSymbolicLink(at: localRepoURL.appendingPathComponent("sub/escape"), withDestinationURL: evilDir) - // Following the symlink would find evil/.gpg-id — must not happen - XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/escape")) - } - - // MARK: Parse .gpg-id - - func testReadGPGIDFile() throws { - try cloneRepository(.withGPGID) - [ - ("", [RSA4096.longFingerprint]), - ("family", [String(NISTP384.longFingerprint.suffix(16))]), - ("personal", [RSA4096.longFingerprint]), - ("shared", [RSA2048.longFingerprint, RSA4096.longFingerprint]), - ("work", [RSA2048.longFingerprint]), - ].forEach { path, expectedKeyIDs in - let foundKeyIDs = passwordStore.findGPGIDs(underPath: path) - XCTAssertEqual(foundKeyIDs, expectedKeyIDs.map { $0.uppercased() }, "Failed for path: \(path)") - } - } - - func testReadEmptyGPGIDFile() throws { - try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil)) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8))) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - } - - func testReadGPGIDFileWithWhitespace() throws { - try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil)) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8))) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) - - XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \nbar foo\n\tbaz\n \n".utf8))) - XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), ["bar foo", "baz"]) - } - - // MARK: Handle .gpg-id - - func testAddPasswordInRoot_WithSingleEntryInPGPIDFile_EncryptsWithThatKey() throws { - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) Defaults.isEnableGPGIDOn = true + let passwordStore = PasswordStore(url: url) + try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master") + expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil) + waitForExpectations(timeout: 3, handler: nil) + + [ + ("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), + ("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), + ].forEach { path, id in + let keyID = findGPGID(from: url.appendingPathComponent(path)) + XCTAssertEqual(keyID, id) + } + + let keychain = AppKeychain.shared + try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) + try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) + try PGPAgent.shared.initKeys() + + let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") + XCTAssertEqual(personal.plainText, "passwordforpersonal\n") + + let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") + XCTAssertEqual(work.plainText, "passwordforwork\n") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") - _ = try passwordStore.add(password: testPassword) + let testPasswordEntity = try passwordStore.add(password: testPassword)! + let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + XCTAssertEqual(testPasswordPlain.plainText, "testpassword") - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) - let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first - XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) - XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() }) - } - - func testEncryptWithSingleKeyViaGPGIDFileInSubDirectory() throws { - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) - Defaults.isEnableGPGIDOn = true - - let testPassword = Password(name: "test", path: "family/test.gpg", plainText: "testpassword") - _ = try passwordStore.add(password: testPassword) - - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) - let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first - XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) - XCTAssertEqual(encryptCall?.keyIDs, [String(NISTP384.longFingerprint.suffix(16))].map { $0.uppercased() }) - } - - func testEncryptWithSingleKeyViaGPGIDFileInParentDir() throws { - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) - Defaults.isEnableGPGIDOn = true - - // /personal doesn't have its own .gpg-id file, but should inherit from the root .gpg-id file - let testPassword = Password(name: "test", path: "personal/test.gpg", plainText: "testpassword") - _ = try passwordStore.add(password: testPassword) - - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) - let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first - XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) - XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() }) - } - - func testEncryptWithMultipleKeysViaGPGIDFile() throws { - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) - Defaults.isEnableGPGIDOn = true - - // /shared uses both RSA2048 and RSA4096 - let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword") - _ = try passwordStore.add(password: testPassword) - - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1) - let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first - XCTAssertEqual(encryptCall?.plainData, testPassword.plainData) - XCTAssertEqual(encryptCall?.keyIDs, RSA2048_RSA4096.longFingerprints.map { $0.uppercased() }) - } - - func testEncryptWithSingleKeyViaGPGFile_MissingKey() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() // Only import RSA4096, but not RSA2048 - Defaults.isEnableGPGIDOn = true - - // /work uses RSA2048, but we didn't import that one - let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword") - XCTAssertThrowsError(try passwordStore.add(password: testPassword)) { - XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased())) - } - } - - func testEncryptWithMultipleKeysViaGPGFile_MissingKey() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() // Only import RSA4096, but not RSA2048 - Defaults.isEnableGPGIDOn = true - - // /shared uses both RSA2048 and RSA4096, but we only imported RSA4096, so encryption should fail since one of the keys is missing - let testPassword = Password(name: "test", path: "shared/test.gpg", plainText: "testpassword") - XCTAssertThrowsError(try passwordStore.add(password: testPassword)) { - XCTAssertEqual($0 as? AppError, .pgpPublicKeyNotFound(keyID: RSA2048.longFingerprint.uppercased())) - } - } - - func testGPGIDDisabledIgnoresGPGIDFile() throws { - try cloneRepository(.withGPGID) - try importSinglePGPKey() // Only import RSA4096, but not RSA2048 + passwordStore.erase() Defaults.isEnableGPGIDOn = false - - // /work uses RSA2048, but we didn't import that one - let testPassword = Password(name: "test", path: "work/test.gpg", plainText: "testpassword") - // this would throw if isEnableGPGIDOn was true, since we are missing the key to encrypt it - _ = try passwordStore.add(password: testPassword) - - // check that we can decrypt it with the key we have, which confirms that it was encrypted without using the .gpg-id file - let decryptedPassword = try decrypt(path: "work/test.gpg", keyID: RSA4096.longFingerprint) - XCTAssertEqual(decryptedPassword.plainText, "testpassword") - - // we can't even decrypt it with RSA2048 - try importMultiplePGPKeys() - XCTAssertThrowsError(try decrypt(path: "work/test.gpg", keyID: RSA2048.longFingerprint)) { - XCTAssertEqual($0 as? AppError, .keyExpiredOrIncompatible) - } } - func testEncryptWithExplicitKeyID_OverridesGPGIDFile() throws { - continueAfterFailure = false // avoid index out of bounds error below - - let mockPGPInterface = setUpMockedPGPInterface() - mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) - try cloneRepository(.withGPGID) - Defaults.isEnableGPGIDOn = true - - // Even though /personal would normally use RSA4096 from the root .gpg-id file, if we explicitly specify a key ID then that should be used instead - let testPassword1 = Password(name: "test1", path: "personal/test1.gpg", plainText: "testpassword1") - _ = try passwordStore.add(password: testPassword1) - let testPassword2 = Password(name: "test2", path: "personal/test2.gpg", plainText: "testpassword2") - _ = try passwordStore.add(password: testPassword2, keyID: RSA2048.longFingerprint) - - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 2) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].plainData, testPassword1.plainData) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[0].keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() }) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].plainData, testPassword2.plainData) - XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls[1].keyIDs, [RSA2048.longFingerprint]) - } - - // MARK: - Helpers - - private enum RemoteRepo { - case empty - case emptyDirs - case withGPGID - - var url: URL { - switch self { - case .empty: - Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty.git") - case .emptyDirs: - Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-empty-dirs.git") - case .withGPGID: - Bundle(for: PasswordStoreTest.self).resourceURL!.appendingPathComponent("Fixtures/password-store-with-gpgid.git") - } - } - - var branchName: String { - switch self { - case .empty: - "main" - case .emptyDirs: - "main" - case .withGPGID: - "master" - } - } - } - - private func cloneRepository(_ remote: RemoteRepo) throws { - expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: self.localRepoURL.path) }, evaluatedWith: nil) - expectation(forNotification: .passwordStoreUpdated, object: nil) - - try passwordStore.cloneRepository(remoteRepoURL: remote.url, branchName: remote.branchName) - - waitForExpectations(timeout: 3, handler: nil) - } - - private func importSinglePGPKey() throws { - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.publicKey) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA4096.privateKey) - try pgpAgent.initKeys() - } - - private func importMultiplePGPKeys() throws { - try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.publicKeys) - try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keyStore.add).importKey(from: RSA2048_RSA4096.privateKeys) - try pgpAgent.initKeys() - } - - private func decrypt(path: String, keyID: String? = nil) throws -> Password { + private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { let entity = passwordStore.fetchPasswordEntity(with: path)! - return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) + return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } } diff --git a/passKitTests/Testbase/TestPGPKeys.swift b/passKitTests/Testbase/TestPGPKeys.swift index fd6d8bf..da2177c 100644 --- a/passKitTests/Testbase/TestPGPKeys.swift +++ b/passKitTests/Testbase/TestPGPKeys.swift @@ -16,7 +16,6 @@ struct PGPTestSet { let publicKey: String let privateKey: String let fingerprint: String - let longFingerprint: String let passphrase: String fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate @@ -29,7 +28,6 @@ struct MultiKeyPGPTestSet { let publicKeys: String let privateKeys: String let fingerprints: [String] - let longFingerprints: [String] let passphrases: [String] } @@ -37,7 +35,6 @@ let RSA2048 = PGPTestSet( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_KEY, fingerprint: "a1024dae", - longFingerprint: "4712286271220db299883ea7062e678da1024dae", passphrase: "passforios" ).collect() @@ -45,7 +42,6 @@ let RSA2048_SUB = PGPTestSet( publicKey: PGP_RSA2048_PUBLIC_KEY, privateKey: PGP_RSA2048_PRIVATE_SUBKEY, fingerprint: "a1024dae", - longFingerprint: "4712286271220db299883ea7062e678da1024dae", passphrase: "passforios" ) @@ -53,7 +49,6 @@ let RSA3072_NO_PASSPHRASE = PGPTestSet( publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE, privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE, fingerprint: "be0f9402", - longFingerprint: "b37cd5669a03f0d46735a2ba35fba3d0be0f9402", passphrase: "" ) @@ -61,7 +56,6 @@ let RSA4096 = PGPTestSet( publicKey: PGP_RSA4096_PUBLIC_KEY, privateKey: PGP_RSA4096_PRIVATE_KEY, fingerprint: "d862027e", - longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e", passphrase: "passforios" ).collect() @@ -69,7 +63,6 @@ let RSA4096_SUB = PGPTestSet( publicKey: PGP_RSA4096_PUBLIC_KEY, privateKey: PGP_RSA4096_PRIVATE_SUBKEY, fingerprint: "d862027e", - longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e", passphrase: "passforios" ) @@ -77,7 +70,6 @@ let ED25519 = PGPTestSet( publicKey: PGP_ED25519_PUBLIC_KEY, privateKey: PGP_ED25519_PRIVATE_KEY, fingerprint: "e9444483", - longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483", passphrase: "passforios" ).collect() @@ -85,7 +77,6 @@ let ED25519_SUB = PGPTestSet( publicKey: PGP_ED25519_PUBLIC_KEY, privateKey: PGP_ED25519_PRIVATE_SUBKEY, fingerprint: "e9444483", - longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483", passphrase: "passforios" ) @@ -93,7 +84,6 @@ let NISTP384 = PGPTestSet( publicKey: PGP_NISTP384_PUBLIC_KEY, privateKey: PGP_NISTP384_PRIVATE_KEY, fingerprint: "5af3c085", - longFingerprint: "bcd364c078585c0607e19c67171c07d25af3c085", passphrase: "soirofssap" ).collect() @@ -101,7 +91,6 @@ let RSA2048_RSA4096 = MultiKeyPGPTestSet( publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY, privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY, fingerprints: ["a1024dae", "d862027e"], - longFingerprints: ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"], passphrases: ["passforios", "passforios"] ) @@ -109,7 +98,6 @@ let ED25519_NISTP384 = MultiKeyPGPTestSet( publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY, privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY, fingerprints: ["e9444483", "5af3c085"], - longFingerprints: ["5fccb081ab8af48972999e2ae750acbfe9444483", "bcd364c078585c0607e19c67171c07d25af3c085"], passphrases: ["passforios", "soirofssap"] ) diff --git a/passShortcuts/passBetaShortcuts.entitlements b/passShortcuts/passBetaShortcuts.entitlements index b6ffc4a..9bcfd12 100644 --- a/passShortcuts/passBetaShortcuts.entitlements +++ b/passShortcuts/passBetaShortcuts.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.org.lysanntranvouez.passforiosbeta + group.me.mssun.passforiosbeta keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta + $(AppIdentifierPrefix)group.me.mssun.passforiosbeta diff --git a/passShortcuts/passShortcuts.entitlements b/passShortcuts/passShortcuts.entitlements index a844039..13c5967 100644 --- a/passShortcuts/passShortcuts.entitlements +++ b/passShortcuts/passShortcuts.entitlements @@ -4,11 +4,11 @@ com.apple.security.application-groups - group.org.lysanntranvouez.passforios + group.me.mssun.passforios keychain-access-groups - $(AppIdentifierPrefix)group.org.lysanntranvouez.passforios + $(AppIdentifierPrefix)group.me.mssun.passforios diff --git a/plans/02-multi-recipient-encryption-plan.md b/plans/02-multi-recipient-encryption-plan.md deleted file mode 100644 index 132cfce..0000000 --- a/plans/02-multi-recipient-encryption-plan.md +++ /dev/null @@ -1,191 +0,0 @@ -# Multi-Recipient Encryption Plan - -## Concept - -The `pass` password store format supports encrypting each password to multiple PGP keys via `.gpg-id` files (one key ID per line). This enables sharing a store with other users — each person imports the same git repository but decrypts with their own private key. When adding or editing a password, it must be encrypted to **all** key IDs listed in `.gpg-id`. - -The app currently has a setting (`isEnableGPGIDOn`) that reads `.gpg-id` for per-directory key selection, but it only supports a single key ID. This plan fixes every layer to support multiple recipients. - -This is standalone — it can be implemented before or after multi-store support. - ---- - -## Current State - -The codebase does **not** support encrypting to multiple public keys. Every layer assumes a single recipient: - -| Layer | Current state | What needs to change | -|-------|--------------|---------------------| -| `.gpg-id` file format | Supports multiple key IDs (one per line) | No change needed | -| `findGPGID(from:)` | Returns the **entire file as one trimmed string** — does not split by newline | Split by newline, return `[String]` | -| `PGPInterface.encrypt()` | Signature: `encrypt(plainData:keyID:)` — singular `keyID: String?` | Add `encrypt(plainData:keyIDs:[String])` or change `keyID` to `keyIDs: [String]?` | -| `GopenPGPInterface` | Creates a `CryptoKeyRing` with **one** public key | Add all recipient public keys to the keyring before encrypting | -| `ObjectivePGPInterface` | Passes `keyring.keys` (all keys, including private) — accidentally multi-recipient but not intentionally | Filter to only the specified public keys, pass those to `ObjectivePGP.encrypt()` | -| `PGPAgent.encrypt()` | Routes to a single key via `keyID: String` | Accept `[String]` and pass through to the interface | -| `PasswordStore.encrypt()` | Calls `findGPGID()` for a single key ID string | Call the updated `findGPGID()`, pass the key ID array | - ---- - -## Implementation - -### 1. `findGPGID(from:) -> [String]` - -Split file contents by newline, trim each line, filter empty lines. Return array of key IDs. - -### 2. `PGPInterface` protocol - -Change `encrypt(plainData:keyID:)` to `encrypt(plainData:keyIDs:)` where `keyIDs: [String]?`. When `nil`, encrypt to the first/default key (backward compatible). - -### 3. `GopenPGPInterface.encrypt()` - -Look up all keys matching the `keyIDs` array from `publicKeys`. Add each to the `CryptoKeyRing` (GopenPGP's `CryptoKeyRing` supports multiple keys via `add()`). Encrypt with the multi-key ring. - -### 4. `ObjectivePGPInterface.encrypt()` - -Filter `keyring.keys` to only the public keys matching the requested `keyIDs`. Pass the filtered array to `ObjectivePGP.encrypt()`. - -### 5. `PGPAgent.encrypt()` - -Update both overloads to accept `keyIDs: [String]?` and pass through to the interface. - -### 6. `PasswordStore.encrypt()` - -Call updated `findGPGID()`, pass the array to `PGPAgent`. - ---- - -## Public Key Management - -When a store lists multiple key IDs in `.gpg-id`, the user needs the public keys of all recipients. The user's own private key is sufficient for decryption (since the message is encrypted to all recipients), but all public keys are needed for re-encryption when editing. - -### Current state - -- The keychain holds **one** `pgpPublicKey` blob and **one** `pgpPrivateKey` blob, but each can contain **multiple concatenated armored key blocks**. Both interface implementations parse all keys from these blobs. -- The import UI (armor paste, URL, file picker) has one public key field + one private key field. Importing **replaces** the set of keys entirely, there is no append mode for adding additional keys or managing existing keys. -- There is no UI for viewing which key IDs are loaded or for importing additional recipient-only public keys, nor for viewing the key metadata. -- There is no UI for viewing or editing `.gpg-id` files, which are the source of truth for which keys are used for encryption. - -### Key storage approach - -Store all public keys as a single concatenated armored blob in the keychain (`pgpPublicKey`). Both interface implementations already parse multi-key blobs into dictionaries/keyrings. This avoids schema changes — we just need to **append** instead of **replace** when importing additional public keys. - -The user's own private key stays as a separate single blob (`pgpPrivateKey`). - -### 7. UI: Import additional recipient public keys - -Add an "Import Recipient Key" action to the PGP key settings (alongside the existing import that sets the user's own key pair). This flow: - -- Imports a public-key-only armored blob -- **Appends** it to the existing `pgpPublicKey` keychain entry (concatenating armored blocks) -- Does **not** touch the private key -- On success, shows the newly imported key ID(s) - -The existing import flow ("Set PGP Keys") continues to replace the user's own key pair (public + private). - -### 8. UI: View loaded key IDs and metadata - -PGP keys carry a **User ID** field, typically in the format `"Name "`. Both GopenPGP (`key.entity.PrimaryIdentity()`) and ObjectivePGP (`key.keyID` + user ID packets) can access this. The app currently doesn't expose it. - -Add key metadata to the `PGPInterface` protocol: - -```swift -struct PGPKeyInfo { - let fingerprint: String // full fingerprint - let shortKeyID: String // last 8 hex chars - let userID: String? // "Name " from the primary identity - let isPrivate: Bool // has a matching private key - let isExpired: Bool - let isRevoked: Bool -} -var keyInfo: [PGPKeyInfo] { get } -``` - -Both `GopenPGPInterface` and `ObjectivePGPInterface` should implement this by iterating their loaded keys. - -Add a read-only section to the PGP key settings showing all loaded public keys. Each row shows: - -- **User ID** (e.g. `"Alice "`) as the primary label — this is the human-readable identifier -- **Short key ID** (e.g. `ABCD1234`) as the secondary label -- Badge/icon if it's the user's own key (has matching private key) vs a recipient-only key -- Badge/icon if expired or revoked -- Swipe-to-delete to remove a recipient public key - -This also informs the `.gpg-id` editing UI (§9) — when the user adds/removes recipients from `.gpg-id`, they see names and emails, not just opaque hex key IDs. - -### 9. UI: View/edit `.gpg-id` files - -When `isEnableGPGIDOn` is enabled, add visibility into `.gpg-id`: - -- In the password detail view, show which key IDs the password is encrypted to (from the nearest `.gpg-id` file) -- In folder navigation, show an indicator on directories that have their own `.gpg-id` -- Tapping the indicator shows the `.gpg-id` contents (list of key IDs) with an option to edit -- Editing `.gpg-id` triggers re-encryption of all passwords in the directory (see §10) - -Note: Viewing `.gpg-id` is low-effort and high-value. Editing is more complex due to re-encryption. These can be split into separate steps. - -### 10. Re-encryption when `.gpg-id` changes - -When the user edits a `.gpg-id` file (adding/removing a recipient), all `.gpg` files in that directory (and subdirectories without their own `.gpg-id`) must be re-encrypted to the new recipient list. This is equivalent to `pass init -p subfolder KEY1 KEY2`. - -Steps: -1. Write the new `.gpg-id` file -2. For each `.gpg` file under the directory: - - Decrypt with the user's private key - - Re-encrypt to the new recipient list - - Overwrite the `.gpg` file -3. Git add all changed files + `.gpg-id` -4. Git commit - -This can be expensive for large directories. Show progress and allow cancellation. - ---- - -## Implementation Order - -| Step | Description | Status | Depends On | -|------|-------------|--------|------------| -| 1 | `findGPGIDs` returns `[String]` + update callers | ✅ Done | — | -| 2 | `PGPInterface` protocol change (`keyIDs: [String]`) | ✅ Done | — | -| 3 | `GopenPGPInterface` multi-key encryption | ✅ Done | Step 2 | -| 4 | `ObjectivePGPInterface` multi-key encryption | ✅ Done | Step 2 | -| 5 | `PGPAgent` updated overloads | ✅ Done | Steps 2-4 | -| 6 | `PasswordStore.encrypt()` uses `[String]` from `findGPGIDs` | ✅ Done | Steps 1+5 | -| 7 | UI: import additional recipient public keys | Not started | Step 5 | -| 8 | UI: view loaded key IDs and metadata | Not started | Step 5 | -| 9a | UI: view `.gpg-id` in password detail / folder view | Not started | Step 1 | -| 9b | UI: edit `.gpg-id` | Not started | Step 9a | -| 10 | Re-encryption when `.gpg-id` changes | Not started | Steps 6+9b | - ---- - -## Testing - -### Pre-work: existing encryption tests - -The `PGPAgentTest` already covers single-key encrypt/decrypt with multiple key types. These serve as the regression baseline. - -### Multi-recipient encryption tests - -- **Test `findGPGID` with multi-line `.gpg-id`**: File with two key IDs on separate lines → returns `[String]` with both. -- **Test `findGPGID` with single-line `.gpg-id`**: Backward compatible → returns `[String]` with one element. -- **Test `findGPGID` with empty lines and whitespace**: Trims and filters correctly. -- **Test `GopenPGPInterface.encrypt` with multiple keys**: Encrypt with two public keys → decrypt succeeds with either private key. -- **Test `ObjectivePGPInterface.encrypt` with multiple keys**: Same as above. -- **Test `PGPAgent.encrypt` with `keyIDs` array**: Routes through correctly to the interface. -- **Test round-trip**: Encrypt with key IDs `[A, B]` → user with private key A can decrypt, user with private key B can decrypt. -- **Test encrypt with single keyID still works**: Backward compatibility — `keyIDs: ["X"]` behaves like the old `keyID: "X"`. -- **Test encrypt with unknown keyID in list**: If one of the key IDs is not in the keyring, appropriate error is thrown. -- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. - -### Key management tests - -- **Test appending recipient public key**: Import user's key pair → append a second public key → both key IDs are available. Original private key still works for decryption. -- **Test removing a recipient public key**: Remove one public key from the concatenated blob → only the remaining key IDs are available. -- **Test replacing key pair doesn't lose recipient keys**: Import user's key pair → add recipient key → re-import user's key pair → recipient key is still present (or: design decision — should re-import clear everything?). - -### `.gpg-id` and re-encryption tests - -- **Test re-encryption**: Edit `.gpg-id` to add a recipient → all passwords in directory are re-encrypted → new recipient can decrypt. -- **Test re-encryption removes access**: Edit `.gpg-id` to remove a recipient → re-encrypted passwords cannot be decrypted with the removed key. -- **Test `.gpg-id` directory scoping**: Subdirectory `.gpg-id` overrides parent. Passwords in subdirectory use subdirectory's recipients. -- **Test multi-key public key import**: Import an armored blob containing multiple public keys → all are available for encryption. diff --git a/plans/03-multi-store-plan.md b/plans/03-multi-store-plan.md deleted file mode 100644 index 11541b2..0000000 --- a/plans/03-multi-store-plan.md +++ /dev/null @@ -1,423 +0,0 @@ -# Multi-Store Support — Implementation Plan - -## Concept - -Each **store** is an independent password repository with its own git remote, credentials, branch, and (optionally) its own PGP key pair. Users can enable/disable individual stores for the password list and separately for AutoFill. Stores can be shared between users who each decrypt with their own key (leveraging the existing `.gpg-id` per-directory mechanism from `pass`). - ---- - -## Phase 1: Improve Test Coverage Before Refactoring - -See [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). This is standalone and should be done before any refactoring to catch regressions. - ---- - -## Phase 2: Data Model — `StoreConfiguration` - -Create a new persistent model for store definitions. This is the foundation everything else builds on. - -### 2.1 Define `StoreConfiguration` as a Core Data entity - -→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests) - -Add a `StoreConfiguration` entity to the existing Core Data model (`pass.xcdatamodeld`), with attributes: - -- `id: UUID` — unique identifier -- `name: String` — display name (e.g. "Personal", "Work") -- `gitURL: URI` (stored as String) -- `gitBranchName: String` -- `gitAuthenticationMethod: String` (raw value of `GitAuthenticationMethod`) -- `gitUsername: String` -- `pgpKeySource: String?` (raw value of `KeySource`) -- `isVisibleInPasswords: Bool` — shown in the password list -- `isVisibleInAutoFill: Bool` — shown in AutoFill -- `sortOrder: Int16` — for user-defined ordering -- `lastSyncedTime: Date?` - -Relationship: `passwords` → to-many `PasswordEntity` (inverse: `store`; cascade delete rule — deleting a store removes all its password entities). - -Using Core Data instead of a separate JSON file because: -- The Core Data stack already exists and is shared across all targets via the app group -- The `StoreConfiguration` ↔ `PasswordEntity` relationship gives referential integrity and cascade deletes for free -- No second persistence mechanism to maintain -- Built-in concurrency/conflict handling - -### 2.2 Define `StoreConfigurationManager` - -→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests) - -Manages the list of stores via Core Data. Provides CRUD, reordering, and lookup by ID. Observable (via `NotificationCenter` or Combine) so UI updates when stores change. - -### 2.3 Migration from single-store - -→ Testing: [T2 — Migration tests](#t2-migration-tests) - -On first launch after upgrade, create a single `StoreConfiguration` from the current `Defaults.*` values and keychain entries. Assign all existing `PasswordEntity` rows to this store. Existing users see no change. - -This is a Core Data model version migration: add the `StoreConfiguration` entity, add the `store` relationship to `PasswordEntity`, and populate it in a post-migration step. - -### 2.4 Per-store secrets - -→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) - -Per-store secrets go in the keychain with namespaced keys: - -- `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"` -- `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` -- The existing `"pgpKeyPassphrase-{keyID}"` scheme already works across stores since it's keyed by PGP key ID. - ---- - -## Phase 3: De-singleton the Backend - -The most invasive but essential change. Requires careful sequencing. - -### 3.1 Parameterize `Globals` paths - -Add a method to compute the per-store repository directory: - -- `repositoryURL(for storeID: UUID) -> URL` — e.g. `Library/password-stores/{storeID}/` - -The database path (`dbPath`) stays single since we use one Core Data database with a relationship. - -### 3.2 Make `PasswordStore` non-singleton - -→ Testing: [T3 — `PasswordStoreManager` tests](#t3-passwordstoremanager-tests), [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) - -Convert to a class that takes a `StoreConfiguration` at init: - -- Each instance owns its own `storeURL`, `gitRepository`, `context` -- Inject `StoreConfiguration` (for git URL, branch, credentials) and a `PGPAgent` instance -- Keep a **`PasswordStoreManager`** that holds all active `PasswordStore` instances (keyed by store ID), lazily creating them -- `PasswordStoreManager` replaces all `PasswordStore.shared` call sites - -### 3.3 Core Data: `PasswordEntity` ↔ `StoreConfiguration` relationship - -→ Testing: [T1 — `StoreConfiguration` entity tests](#t1-storeconfiguration-entity-tests), [T6 — `PasswordEntity` fetch filtering tests](#t6-passwordentity-fetch-filtering-tests) - -Add a `store` relationship (to-one) on `PasswordEntity` pointing to `StoreConfiguration` (inverse: `passwords`, to-many, cascade delete). This replaces the need for a separate `storeID` UUID attribute — the relationship provides referential integrity and cascade deletes. - -All `PasswordEntity` fetch requests must be updated to filter by store (or by set of visible stores for the password list / AutoFill). The `initPasswordEntityCoreData(url:in:)` method already takes a URL parameter; pass the per-store URL and set the `store` relationship on each created entity. - -### 3.4 Make `PGPAgent` per-store - -→ Testing: [T4 — Per-store `PasswordStore` tests](#t4-per-store-passwordstore-tests) (encrypt/decrypt with per-store keys) - -Remove the singleton. `PasswordStore` instances each hold an optional `PGPAgent`. Stores sharing the same PGP key pair just load the same keychain entries. Stores using different keys load different ones. The `KeyStore` protocol already supports this — just pass different key names. - -### 3.5 Make `GitCredential` per-store - -→ Testing: [T5 — Per-store keychain namespace tests](#t5-per-store-keychain-namespace-tests) - -Already not a singleton, just reads from `Defaults`. Change it to read from `StoreConfiguration` + namespaced keychain keys instead. - ---- - -## Phase 4: Settings UI — Store Management - -### 4.1 New "Stores" settings section - -Replace the current single "Password Repository" and "PGP Key" rows with a section listing all configured stores, plus an "Add Store" button: - -- Each store row shows: name, git host, sync status indicator -- Tapping a store opens `StoreSettingsTableViewController` -- Swipe-to-delete removes a store (with confirmation) -- Drag-to-reorder for sort order - -### 4.2 `StoreSettingsTableViewController` - -Per-store settings screen: - -- Store name (editable text field) -- **Repository section**: Git URL, branch, username, auth method (reuse existing `GitRepositorySettingsTableViewController` logic, but scoped to this store's config) -- **PGP Key section**: Same import options as today but scoped to this store's keychain namespace. Add an option "Use same key as [other store]" for convenience. -- **Visibility section**: Two toggles — "Show in Passwords", "Show in AutoFill" -- **Sync section**: Last synced time, manual sync button -- **Danger zone**: Delete store (see §4.4 for full cleanup steps) - -### 4.3 Migrate existing settings screens - -`GitRepositorySettingsTableViewController`, `PGPKeyArmorImportTableViewController`, etc. currently read/write global `Defaults`. Refactor them to accept a `StoreConfiguration` and read/write to that store's Core Data entity and namespaced keychain keys instead. - -### 4.4 Store lifecycle: adding a store - -→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) - -Currently, configuring git settings triggers a clone immediately (`GitRepositorySettingsTableViewController.save()` → `cloneAndSegueIfSuccess()`), and the clone rebuilds Core Data from the filesystem. The multi-store equivalent: - -1. User taps "Add Store" → presented with `StoreSettingsTableViewController` -2. User fills in store name, git URL, branch, username, auth method -3. User imports PGP keys (public + private) for this store -4. User taps "Save" → creates a `StoreConfiguration` entity in Core Data -5. Clone is triggered for this store: - - Compute per-store repo directory: `Library/password-stores/{storeID}/` - - Call `PasswordStore.cloneRepository()` scoped to that directory - - On success: BFS-walk the cloned repo, create `PasswordEntity` rows linked to this `StoreConfiguration` via the `store` relationship - - On success: validate `.gpg-id` exists (warn if missing, since decryption will fail) - - On failure: delete the `StoreConfiguration` entity (cascade deletes any partial `PasswordEntity` rows), clean up the repo directory, remove keychain entries for this store ID -6. Post `.passwordStoreUpdated` notification so the password list refreshes - -### 4.5 Store lifecycle: removing a store - -→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) - -Currently `erase()` nukes everything globally. Per-store removal must be scoped: - -1. User confirms deletion (destructive action sheet) -2. Cleanup steps: - - Delete the repo directory: `Library/password-stores/{storeID}/` (rm -rf) - - Delete `StoreConfiguration` entity from Core Data → cascade-deletes all linked `PasswordEntity` rows automatically - - Remove namespaced keychain entries: `"{storeID}.gitPassword"`, `"{storeID}.gitSSHPrivateKeyPassphrase"`, `"{storeID}.sshPrivateKey"`, `"{storeID}.pgpPublicKey"`, `"{storeID}.pgpPrivateKey"` - - Drop the in-memory `PasswordStore` instance from `PasswordStoreManager` - - Post `.passwordStoreUpdated` so the password list refreshes -3. PGP key passphrase entries (`"pgpKeyPassphrase-{keyID}"`) may be shared with other stores using the same key — only remove if no other store references that key ID - -### 4.6 Store lifecycle: re-cloning / changing git URL - -→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) - -When the user changes the git URL or branch of an existing store (equivalent to today's "overwrite" flow): - -1. Delete the existing repo directory for this store -2. Delete all `PasswordEntity` rows linked to this `StoreConfiguration` (but keep the `StoreConfiguration` entity itself) -3. Clone the new repo into the store's directory -4. Rebuild `PasswordEntity` rows from the new clone, linked to the same `StoreConfiguration` -5. Clear and re-prompt for git credentials - -### 4.7 Global "Erase all data" - -→ Testing: [T7 — Store lifecycle integration tests](#t7-store-lifecycle-integration-tests) (test global erase) - -The existing "Erase Password Store Data" action in Advanced Settings should: - -1. Delete all `StoreConfiguration` entities (cascade-deletes all `PasswordEntity` rows) -2. Delete all repo directories under `Library/password-stores/` -3. Remove all keychain entries (`AppKeychain.shared.removeAllContent()`) -4. Clear all UserDefaults (`Defaults.removeAll()`) -5. Clear passcode, uninit all PGP agents, drop all `PasswordStore` instances -6. Post `.passwordStoreErased` - ---- - -## Phase 5: Password List UI — Multi-Store Browsing - -### 5.1 Unified password list - -`PasswordNavigationViewController` should show passwords from all visible stores together: - -- **Folder mode**: Add a top-level grouping by store name, then the folder hierarchy within each store. The store name row could have a distinct style (e.g. bold, with a colored dot or icon). -- **Flat mode**: Show all passwords from all visible stores. Subtitle or accessory showing which store each password belongs to. -- **Search**: Searches across all visible stores simultaneously. Results annotated with store name. - -### 5.2 Password detail - -`PasswordDetailTableViewController` needs to know which store a password belongs to (to decrypt with the right `PGPAgent` and write changes back to the right repo). Pass the store context through from the list. - -### 5.3 Add password flow - -`AddPasswordTableViewController` needs a store picker if multiple stores are visible. Default to a "primary" store or the last-used one. - -### 5.4 Sync - -→ Testing: [T9 — Sync tests](#t9-sync-tests) - -Pull-to-refresh in the password list syncs all visible stores (sequentially or in parallel). Show per-store sync status. Allow syncing individual stores from their settings or via long-press. - ---- - -## Phase 6: AutoFill Extension - -### 6.1 Multi-store AutoFill - -→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) - -`CredentialProviderViewController`: - -- Fetch passwords from all stores where `isVisibleInAutoFill == true` -- The "Suggested" section should search across all AutoFill-visible stores -- Each password entry carries its store context for decryption -- No store picker needed — just include all enabled stores transparently -- Consider showing store name in the cell subtitle for disambiguation - -### 6.2 QuickType integration - -→ Testing: [T8 — AutoFill multi-store tests](#t8-autofill-multi-store-tests) (store ID in `recordIdentifier`) - -`provideCredentialWithoutUserInteraction` needs to try the right store's PGP agent for decryption. Since it gets a `credentialIdentity` (which contains a `recordIdentifier` = password path), the path must now encode or be mappable to a store ID. - ---- - -## Phase 7: Extensions & Shortcuts - -### 7.1 passExtension (share extension) - -Same multi-store search as AutoFill. Minor. - -### 7.2 Shortcuts - -`SyncRepositoryIntentHandler`: - -- Add a store parameter to the intent (optional — if nil, sync all stores) -- Register each store as a Shortcut parameter option -- Support "Sync All" and "Sync [store name]" - ---- - -## Phase 8: Multi-Recipient Encryption - -See [02-multi-recipient-encryption-plan.md](02-multi-recipient-encryption-plan.md). This is standalone and can be implemented before or after multi-store support. In a multi-store context, `isEnableGPGIDOn` becomes a per-store setting. - ---- - -## Implementation Order - -| Step | Phase | Description | Depends On | -|------|-------|-------------|------------| -| 1 | 1 | Improve test coverage (see [separate plan](01-improve-test-coverage-plan.md)) | — | -| 2a | 2 | `StoreConfiguration` Core Data entity + relationship to `PasswordEntity` + model migration | Phase 1 | -| 2b | 2 | `StoreConfigurationManager` + single-store migration from existing Defaults/keychain | Step 2a | -| 2t | T | Tests: `StoreConfiguration` CRUD, cascade delete, migration (T1, T2) | Steps 2a+2b | -| 3a | 3 | Parameterize `Globals` paths (per-store repo directory) | Step 2a | -| 3b | 3 | Namespace keychain keys per store | Step 2a | -| 3bt | T | Tests: per-store keychain namespace (T5) | Step 3b | -| 3c | 3 | De-singleton `PGPAgent` | Steps 2a+3a+3b | -| 3d | 3 | De-singleton `PasswordStore` → `PasswordStoreManager` | Steps 2b-3c | -| 3dt | T | Tests: `PasswordStoreManager`, per-store `PasswordStore`, entity filtering (T3, T4, T6) | Step 3d | -| 3e | 3 | Per-store `GitCredential` | Steps 3b+3d | -| 3f | 3 | Store lifecycle: add/clone, remove/cleanup, re-clone, global erase | Steps 3d+3e | -| 3ft | T | Tests: store lifecycle integration (T7) | Step 3f | -| 4a | 4 | Store management UI (add/edit/delete/reorder) | Step 3f | -| 4b | 4 | Migrate existing settings screens to per-store | Step 4a | -| 5a | 5 | Multi-store password list | Step 3d | -| 5b | 5 | Multi-store add/edit/detail | Step 5a | -| 5c | 5 | Multi-store sync | Steps 3e+5a | -| 5ct | T | Tests: sync (T9) | Step 5c | -| 6a | 6 | Multi-store AutoFill | Step 3d | -| 6t | T | Tests: AutoFill multi-store (T8) | Step 6a | -| 7a | 7 | Multi-store Shortcuts | Step 3d | -| 8a | 8 | Multi-recipient encryption (see [separate plan](02-multi-recipient-encryption-plan.md)) | Step 3d | - ---- - -## Testing Plan - -For baseline test coverage of existing code, see [01-improve-test-coverage-plan.md](01-improve-test-coverage-plan.md). - -### Testing new multi-store code - -#### T1: `StoreConfiguration` entity tests - -- **Test CRUD**: Create, read, update, delete `StoreConfiguration` entities. -- **Test cascade delete**: Delete a `StoreConfiguration` → verify all linked `PasswordEntity` rows are deleted. -- **Test relationship integrity**: Create `PasswordEntity` rows linked to a store → verify fetching by store returns the right entities. -- **Test `StoreConfigurationManager`**: Create, list, reorder, delete stores via the manager. - -#### T2: Migration tests - -- **Test fresh install**: No existing data → no `StoreConfiguration` created, app works. -- **Test upgrade migration from single-store**: - 1. Set up a pre-migration Core Data database (using the old model version) with `PasswordEntity` rows, populate `Defaults` with git URL/branch/username, and populate keychain with PGP + SSH keys. - 2. Run the migration. - 3. Verify: one `StoreConfiguration` exists with values from Defaults, all `PasswordEntity` rows are linked to it, keychain entries are namespaced under the new store's ID. -- **Test idempotency**: Running migration twice doesn't create duplicate stores. -- **Test migration with empty repo** (no passwords, just settings): Still creates a `StoreConfiguration`. - -#### T3: `PasswordStoreManager` tests - -- **Test store lookup by ID**. -- **Test lazy instantiation**: Requesting a store creates `PasswordStore` on demand. -- **Test listing visible stores** (filtered by `isVisibleInPasswords` / `isVisibleInAutoFill`). -- **Test adding/removing stores updates the manager**. - -#### T4: Per-store `PasswordStore` tests - -- **Test clone scoped to per-store directory**: Clone into `Library/password-stores/{storeID}/`, verify `PasswordEntity` rows are linked to the right `StoreConfiguration`. -- **Test two stores independently**: Clone two different repos, verify each store's entities are separate, deleting one doesn't affect the other. -- **Test `eraseStoreData` scoped to one store**: Only that store's directory and entities are deleted. -- **Test encrypt/decrypt with per-store PGP keys**: Store A uses key pair X, store B uses key pair Y, each can only decrypt its own passwords. -- **Test store sharing one PGP key pair**: Two stores referencing the same keychain entries both decrypt correctly. - -#### T5: Per-store keychain namespace tests - -- **Test namespaced keys don't collide**: Store A's `"{A}.gitPassword"` and store B's `"{B}.gitPassword"` are independent. -- **Test `removeAllContent(withPrefix:)`**: Removing store A's keys doesn't affect store B's. -- **Test `pgpKeyPassphrase-{keyID}`** shared across stores using the same key. - -#### T6: `PasswordEntity` fetch filtering tests - -- **Test `fetchAll` filtered by one store**. -- **Test `fetchAll` filtered by multiple visible stores** (the AutoFill / password list scenario). -- **Test `fetchUnsynced` filtered by store**. -- **Test search across multiple stores**. - -#### T7: Store lifecycle integration tests - -- **Test add store flow**: Create config → clone → BFS walk → entities linked → notification posted. -- **Test remove store flow**: Delete config → cascade deletes entities → repo directory removed → keychain cleaned → notification posted. -- **Test re-clone flow**: Change git URL → old entities deleted → new clone → new entities → same `StoreConfiguration`. -- **Test global erase**: Multiple stores → all gone. -- **Test clone failure cleanup**: Clone fails → `StoreConfiguration` deleted → no orphan entities or directories. - -#### T8: AutoFill multi-store tests - -- **Test credential listing from multiple stores**: Entries from all AutoFill-visible stores appear. -- **Test store ID encoded in `recordIdentifier`**: Can map a credential identity back to the correct store for decryption. -- **Test filtering**: Only `isVisibleInAutoFill == true` stores appear. - -#### T9: Sync tests - -- **Test pull updates one store's entities without affecting others**. -- **Test sync-all triggers pull for each visible store**. - -### Test infrastructure additions needed - -- **Multi-store `CoreDataTestCase`**: Extend `CoreDataTestCase` to support the new model version with `StoreConfiguration`. Provide a helper to create a `StoreConfiguration` + linked entities in one call. -- **Pre-migration database fixture**: A snapshot of the old Core Data model (without `StoreConfiguration`) to use in migration tests. Can be a `.sqlite` file committed to the test bundle. - ---- - -## Risks & Considerations - -- **Data migration**: Existing users must be migrated seamlessly. The migration (steps 2a-2b) should be idempotent and tested thoroughly. -- **Core Data migration**: Adding the `StoreConfiguration` entity and the `store` relationship on `PasswordEntity` requires a lightweight migration (new entity + new optional relationship). The post-migration step creates a default `StoreConfiguration` from existing Defaults and assigns all existing `PasswordEntity` rows to it. -- **Memory**: Multiple `PasswordStore` instances each holding a `GTRepository` and `PGPAgent` — lazy instantiation is important. Only active/visible stores should be loaded. -- **Concurrency**: Git operations (pull/push) across multiple stores should not block each other. Use per-store serial queues. -- **AutoFill performance**: The extension has strict memory limits (~30MB). Loading all stores' Core Data is fine (single DB), but loading multiple PGP agents may be expensive. Decrypt lazily, only when the user selects a password. -- **Backward compatibility**: Older versions won't understand the new data layout. Consider a one-way migration flag. - ---- - -## Context - -### Prompt - -I want to add support for several separate password repositories, each with a unique repository connection (url, authnetication), and potentially separate encryption/decryption keys. - -Another GUI app that supports this is QtPass. There is information about this its readme: https://raw.githubusercontent.com/IJHack/QtPass/refs/heads/main/README.md -It calls it "profiles". I would probably call it "stores". - -I want to be able to configure which stores are enabled when I view the list, and separately also for the autofill feature. - -It should be possible to share a store with another user (who would be using a separate key on their end). - -Make a plan for what needs to be done to support this in this application. - -### Key Architecture Facts -- `PasswordStore.shared` singleton referenced from ~20+ call sites (app, AutoFill, passExtension, Shortcuts) -- `PGPAgent.shared` singleton holds single key pair -- `Globals` has all paths as `static let` (single repo, single DB, single key paths) -- `DefaultsKeys` — all git/PGP settings single-valued in shared UserDefaults -- `AppKeychain.shared` — flat keys, no per-store namespace -- Core Data: single `PasswordEntity` entity, no store discriminator, single SQLite DB -- `PersistenceController.shared` — single NSPersistentContainer -- UI: UITabBarController with 2 tabs (Passwords, Settings). Passwords tab uses PasswordNavigationViewController -- AutoFill: CredentialProviderViewController uses PasswordStore.shared directly -- App group + keychain group shared across all targets -- `.gpg-id` per-directory key selection already exists (closest to multi-key concept) -- QtPass calls them "profiles" — each can have different git repo and GPG key - -### User Requirements -- Multiple password stores, each with unique repo connection (URL, auth) and potentially separate PGP keys -- Call them "stores" (not profiles) -- Configure which stores are visible in password list vs AutoFill separately -- Support sharing a store with another user (who uses a different key) diff --git a/scripts/gopenpgp_build.sh b/scripts/gopenpgp_build.sh index b851cb9..98dc8bf 100755 --- a/scripts/gopenpgp_build.sh +++ b/scripts/gopenpgp_build.sh @@ -14,7 +14,7 @@ GOPENPGP_PATH="$CHECKOUT_PATH/gopenpgp" mkdir -p "$OUTPUT_PATH" mkdir -p "$CHECKOUT_PATH" -git clone --depth 1 --branch "$GOPENPGP_VERSION" https://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH" +git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH" pushd "$GOPENPGP_PATH" mkdir -p dist