Compare commits

...
Sign in to create a new pull request.

44 commits

Author SHA1 Message Date
Lysann Tranvouez
b1314dd8c3 Merge branch 'feature/multi-key-support' into feature/multi-repo 2026-03-12 22:52:10 +01:00
Lysann Tranvouez
4d564f570b plan updates 2026-03-12 22:51:28 +01:00
Lysann Tranvouez
cd6dd43dae do not search too far for .gpg-id + lots of tests for that 2026-03-12 22:47:39 +01:00
Lysann Tranvouez
5c416bfb21 test .gpg-id support
mostly using mocks
2026-03-12 09:33:26 +01:00
Lysann Tranvouez
38649f96fe mocking prep: PasswordStoreTest has its own KeyStore 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
e3de11f71c PasswordStore: allow to pass in a specific PGPAgent implementation (for testing) 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
77f85ccdd1 support reading several key IDs in .gpg-id files 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
566b7253f5 remove overload, caller can take care of it in 2 step process 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
e32402b807 streamline gpg-id handing
* decrypt should not care about it at all
* PasswordStore.decrypt always forwards the passed in keyID, even when
  gpg-id handling is disabled
* PasswordStore.encrypt: streamlined, but should be same behavior
2026-03-12 09:32:04 +01:00
Lysann Tranvouez
054f333bac deduplicate decrypt logic in PGPAgent 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
b103337083 move function to be closer to related one 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
5a92b6fda7 clarify public vs private keys + make prvate key IDs available 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
4e19d9e714 remove irrelevant properties from mocks 2026-03-12 09:32:04 +01:00
Lysann Tranvouez
9e3e3d1134 remove fallback behavior
this logic should not be relevant anywhere
2026-03-12 09:32:04 +01:00
Lysann Tranvouez
e69e590e36 replace calls to deprecated function
changes PasswordStore.encrypt behavior when .gpg-id support is off
(default):
  old:
    * ignores passed in keyID
    * encrypt with first public key in keychain (gopenPGP), or entire
      keychain (ObjectivePGP)
  new:
    * honor passed in keyID
    * encrypt with all keys in keychain
2026-03-12 09:32:04 +01:00
Lysann Tranvouez
09b0b150ce PGPAgent can encrypt with multiple keys 2026-03-11 14:12:24 +01:00
Lysann Tranvouez
b7873e6d72 move functions around 2026-03-11 14:11:37 +01:00
Lysann Tranvouez
e728f26a20 move and rename test functions 2026-03-11 14:11:29 +01:00
Lysann Tranvouez
84eaf4ad7d PGPInterface can encrypt with multiple keys, PGPAgent can encrypt with all keys 2026-03-11 14:11:07 +01:00
Lysann Tranvouez
8d4f3af475 decryption: GopenPGPInterface tries to identify decryption key from message metadata
So the system can have multiple private keys, and the caller doesn't
need to specify a specific one regardless.

Ideally: If there are several matches we could also take into account
which keys have already been unlocked (or passthrases saved in
keychain). Right now it only grabs the first match.
2026-03-11 13:41:59 +01:00
Lysann Tranvouez
f1cb5d27be reference new version of gopenpgp with a new helper (HelperPassGetHexSubkeyIDsJSON) 2026-03-11 13:39:55 +01:00
Lysann Tranvouez
2ae751044c decryption: always request key passphrase based on key ID 2026-03-11 13:32:21 +01:00
Lysann Tranvouez
d136175d93 add detailed API tests checking how calls to PGPAgent propagate to the underlying interface
this is refactoring support, so that we can notice changes in how the
underlying APIs are called, and make changes intentionally when needed,
instead of accidentally.
2026-03-11 11:36:36 +01:00
Lysann Tranvouez
76db529764 add long fingerprints for test keys 2026-03-11 10:34:54 +01:00
Lysann Tranvouez
c4f81c16eb move variables into smaller scope 2026-03-10 16:51:40 +01:00
Lysann Tranvouez
f0c21dd880 Merge branch 'feature/more-tests-pr' into feature/multi-key-support 2026-03-09 23:08:48 +01:00
Lysann Tranvouez
55b682b4b0 improve directory deletion/editing handling 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
b8b7e1f913 PersistenceController tests 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
cde82d956b rename file to match contained class 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
4c21ab99ad add tests for AppKeychain 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
c6a4f80503 add initPasswordEntityCoreData tests 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
e1da1988b4 add save and decrypt round trip 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
e195280efc test resetting local changes 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
c3bfa861f4 check file system and commits upon changes to store 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
98646242e0 fix deleting directory
this used to corrupt the local state (password entities remained in DB
but files/dirs were removed from git and disk)
2026-03-09 22:58:05 +01:00
Lysann Tranvouez
12c8c04203 test add, edit, delete 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
98ad323431 check notification center notifications 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
e5650ec756 add encrypt-save-decrypt roundtrip test 2026-03-09 22:58:05 +01:00
Lysann Tranvouez
60999c7eab more tests: entity fetching + erase 2026-03-09 22:56:27 +01:00
Lysann Tranvouez
ef188fcfba basic core data tests upon clone 2026-03-09 22:56:27 +01:00
Lysann Tranvouez
85972a02c3 include repo as text fixture, no need to clone from actual github 2026-03-09 22:56:27 +01:00
Lysann Tranvouez
17b6bb8bc2 fix test cleanup 2026-03-09 22:20:04 +01:00
Lysann Tranvouez
c30e1922f1 feature implementation plans 2026-03-09 15:02:48 +01:00
Lysann Tranvouez
7db85c9939 DO NOT MERGE
local development signing fixes
2026-03-08 21:08:36 +01:00
73 changed files with 2577 additions and 335 deletions

View file

@ -114,6 +114,11 @@
5F9D7B0D27AF6F7500A8AB22 /* CryptoTokenKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 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, ); }; }; 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, ); }; }; 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 */; }; 9A1D1CE526E5D1CE0052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE426E5D1CE0052028E /* OneTimePassword */; };
9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; }; 9A1D1CE726E5D2230052028E /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1D1CE626E5D2230052028E /* OneTimePassword */; };
9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; }; 9A1F47FA26E5CF4B000C0E01 /* OneTimePassword in Frameworks */ = {isa = PBXBuildFile; productRef = 9A1F47F926E5CF4B000C0E01 /* OneTimePassword */; };
@ -195,7 +200,7 @@
DC4914961E434301007FF592 /* LabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914941E434301007FF592 /* LabelTableViewCell.swift */; }; DC4914961E434301007FF592 /* LabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914941E434301007FF592 /* LabelTableViewCell.swift */; };
DC4914991E434600007FF592 /* PasswordDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */; }; DC4914991E434600007FF592 /* PasswordDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */; };
DC5F385B1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */; }; DC5F385B1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */; };
DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */; }; DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474522D20DD0C004B4BBC /* PersistenceController.swift */; };
DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; }; DC64745C2D29BE9B004B4BBC /* PasswordEntityTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */; };
DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; }; DC64745D2D29BEA9004B4BBC /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */; };
DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; }; DC64745F2D45B240004B4BBC /* GitRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC64745E2D45B23A004B4BBC /* GitRepository.swift */; };
@ -422,6 +427,11 @@
30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = "<group>"; }; 30F6C1B327664C7200BE5AB2 /* SVProgressHUD.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = SVProgressHUD.xcframework; path = Carthage/Build/SVProgressHUD.xcframework; sourceTree = "<group>"; };
30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = "<group>"; }; 30FD2F77214D9E0E005E0A92 /* ParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParserTest.swift; sourceTree = "<group>"; };
5F9D7B0C27AF6F7300A8AB22 /* CryptoTokenKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CryptoTokenKit.framework; path = System/Library/Frameworks/CryptoTokenKit.framework; sourceTree = SDKROOT; }; 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 = "<group>"; };
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerTest.swift; sourceTree = "<group>"; };
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPGPInterface.swift; sourceTree = "<group>"; };
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PGPAgentLowLevelTests.swift; sourceTree = "<group>"; };
8AD8EBF22F5E268D007475AB /* Fixtures */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Fixtures; sourceTree = "<group>"; };
9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; }; 9A1EF0B324C50DD80074FEAC /* passBeta.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBeta.entitlements; sourceTree = "<group>"; };
9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; }; 9A1EF0B424C50E780074FEAC /* passBetaAutoFillExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaAutoFillExtension.entitlements; sourceTree = "<group>"; };
9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = "<group>"; }; 9A1EF0B524C50EE00074FEAC /* passBetaExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = passBetaExtension.entitlements; sourceTree = "<group>"; };
@ -498,7 +508,7 @@
DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = "<group>"; }; DC4914941E434301007FF592 /* LabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LabelTableViewCell.swift; sourceTree = "<group>"; };
DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = "<group>"; }; DC4914981E434600007FF592 /* PasswordDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordDetailTableViewController.swift; sourceTree = "<group>"; };
DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = "<group>"; }; DC5F385A1E56AADB00C69ACA /* PGPKeyArmorImportTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PGPKeyArmorImportTableViewController.swift; sourceTree = "<group>"; };
DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = "<group>"; }; DC6474522D20DD0C004B4BBC /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = "<group>"; }; DC6474582D29BD43004B4BBC /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = "<group>"; };
DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = "<group>"; }; DC6474592D29BD43004B4BBC /* PasswordEntityTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEntityTest.swift; sourceTree = "<group>"; };
DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = "<group>"; }; DC64745E2D45B23A004B4BBC /* GitRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitRepository.swift; sourceTree = "<group>"; };
@ -624,6 +634,7 @@
301F6464216164670071A4CE /* Helpers */ = { 301F6464216164670071A4CE /* Helpers */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8A4716682F5EF56900C7A64D /* AppKeychainTest.swift */,
3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */, 3032328922C9FBA2009EBD9C /* KeyFileManagerTest.swift */,
); );
path = Helpers; path = Helpers;
@ -761,6 +772,30 @@
path = Crypto; path = Crypto;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8A4716702F5EF7A900C7A64D /* Controllers */ = {
isa = PBXGroup;
children = (
8A47166F2F5EF7A900C7A64D /* PersistenceControllerTest.swift */,
);
path = Controllers;
sourceTree = "<group>";
};
8AB3AD8E2F615FD70081DE16 /* Mocks */ = {
isa = PBXGroup;
children = (
8AB3AD8A2F615FA50081DE16 /* MockPGPInterface.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
8AB3AD8F2F61600B0081DE16 /* LowLevel */ = {
isa = PBXGroup;
children = (
8AB3AD8B2F615FA50081DE16 /* PGPAgentLowLevelTests.swift */,
);
path = LowLevel;
sourceTree = "<group>";
};
9A58664F25AADB66006719C2 /* Services */ = { 9A58664F25AADB66006719C2 /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -879,11 +914,15 @@
A26075861EEC6F34005DB03E /* passKitTests */ = { A26075861EEC6F34005DB03E /* passKitTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
8A4716702F5EF7A900C7A64D /* Controllers */,
DC64745A2D29BD43004B4BBC /* CoreData */, DC64745A2D29BD43004B4BBC /* CoreData */,
30A86F93230F235800F821A4 /* Crypto */, 30A86F93230F235800F821A4 /* Crypto */,
30BAC8C322E3BA4300438475 /* Testbase */, 30BAC8C322E3BA4300438475 /* Testbase */,
30697C5521F63F870064FCAC /* Extensions */, 30697C5521F63F870064FCAC /* Extensions */,
8AD8EBF22F5E268D007475AB /* Fixtures */,
301F6464216164670071A4CE /* Helpers */, 301F6464216164670071A4CE /* Helpers */,
8AB3AD8F2F61600B0081DE16 /* LowLevel */,
8AB3AD8E2F615FD70081DE16 /* Mocks */,
30C015A7214ED378005BB6DF /* Models */, 30C015A7214ED378005BB6DF /* Models */,
30C015A6214ED32A005BB6DF /* Parser */, 30C015A6214ED32A005BB6DF /* Parser */,
30B4C7BB24085A3C008B86F7 /* Passwords */, 30B4C7BB24085A3C008B86F7 /* Passwords */,
@ -913,7 +952,7 @@
children = ( children = (
30697C3121F63C8B0064FCAC /* PasscodeLockPresenter.swift */, 30697C3121F63C8B0064FCAC /* PasscodeLockPresenter.swift */,
30697C3221F63C8B0064FCAC /* PasscodeLockViewController.swift */, 30697C3221F63C8B0064FCAC /* PasscodeLockViewController.swift */,
DC6474522D20DD0C004B4BBC /* CoreDataStack.swift */, DC6474522D20DD0C004B4BBC /* PersistenceController.swift */,
); );
path = Controllers; path = Controllers;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1292,14 +1331,10 @@
TargetAttributes = { TargetAttributes = {
30A69944240EED5E00B7D967 = { 30A69944240EED5E00B7D967 = {
CreatedOnToolsVersion = 11.3; CreatedOnToolsVersion = 11.3;
DevelopmentTeam = 4WDM8E95VU;
ProvisioningStyle = Manual;
}; };
A239F5942158C08B00576CBF = { A239F5942158C08B00576CBF = {
CreatedOnToolsVersion = 10.0; CreatedOnToolsVersion = 10.0;
DevelopmentTeam = 4WDM8E95VU;
LastSwiftMigration = 1020; LastSwiftMigration = 1020;
ProvisioningStyle = Manual;
SystemCapabilities = { SystemCapabilities = {
com.apple.ApplicationGroups.iOS = { com.apple.ApplicationGroups.iOS = {
enabled = 1; enabled = 1;
@ -1322,9 +1357,7 @@
}; };
A26700231EEC466A00176B8A = { A26700231EEC466A00176B8A = {
CreatedOnToolsVersion = 8.3.3; CreatedOnToolsVersion = 8.3.3;
DevelopmentTeam = 4WDM8E95VU;
LastSwiftMigration = 1020; LastSwiftMigration = 1020;
ProvisioningStyle = Manual;
SystemCapabilities = { SystemCapabilities = {
com.apple.ApplicationGroups.iOS = { com.apple.ApplicationGroups.iOS = {
enabled = 1; enabled = 1;
@ -1336,16 +1369,13 @@
}; };
DC13B14D1E8640810097803F = { DC13B14D1E8640810097803F = {
CreatedOnToolsVersion = 8.3; CreatedOnToolsVersion = 8.3;
DevelopmentTeam = 4WDM8E95VU;
LastSwiftMigration = 1020; LastSwiftMigration = 1020;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
TestTargetID = DC917BD21E2E8231000FDF54; TestTargetID = DC917BD21E2E8231000FDF54;
}; };
DC917BD21E2E8231000FDF54 = { DC917BD21E2E8231000FDF54 = {
CreatedOnToolsVersion = 8.2.1; CreatedOnToolsVersion = 8.2.1;
DevelopmentTeam = 4WDM8E95VU;
LastSwiftMigration = 1020; LastSwiftMigration = 1020;
ProvisioningStyle = Manual;
SystemCapabilities = { SystemCapabilities = {
com.apple.ApplicationGroups.iOS = { com.apple.ApplicationGroups.iOS = {
enabled = 1; enabled = 1;
@ -1436,6 +1466,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
8AD8EBF32F5E2723007475AB /* Fixtures in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -1613,7 +1644,7 @@
3087574F2343E42A00B971A2 /* Colors.swift in Sources */, 3087574F2343E42A00B971A2 /* Colors.swift in Sources */,
30697C2C21F63C5A0064FCAC /* FileManagerExtension.swift in Sources */, 30697C2C21F63C5A0064FCAC /* FileManagerExtension.swift in Sources */,
30697C3321F63C8B0064FCAC /* PasscodeLockPresenter.swift in Sources */, 30697C3321F63C8B0064FCAC /* PasscodeLockPresenter.swift in Sources */,
DC6474532D20DD0C004B4BBC /* CoreDataStack.swift in Sources */, DC6474532D20DD0C004B4BBC /* PersistenceController.swift in Sources */,
30697C3D21F63C990064FCAC /* UIViewExtension.swift in Sources */, 30697C3D21F63C990064FCAC /* UIViewExtension.swift in Sources */,
30697C3A21F63C990064FCAC /* UIViewControllerExtension.swift in Sources */, 30697C3A21F63C990064FCAC /* UIViewControllerExtension.swift in Sources */,
30697C2E21F63C5A0064FCAC /* Utils.swift in Sources */, 30697C2E21F63C5A0064FCAC /* Utils.swift in Sources */,
@ -1632,13 +1663,17 @@
30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */, 30A86F95230F237000F821A4 /* CryptoFrameworkTest.swift in Sources */,
30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */, 30A1D2AC21B32C2A00E2D1F7 /* TokenBuilderTest.swift in Sources */,
30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */, 30DAFD4C240985E3002456E7 /* Array+SlicesTest.swift in Sources */,
8A4716712F5EF7A900C7A64D /* PersistenceControllerTest.swift in Sources */,
301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */, 301F646D216166AA0071A4CE /* AdditionFieldTest.swift in Sources */,
9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */, 9ADC954124418A5F0005402E /* PasswordStoreTest.swift in Sources */,
8AB3AD8C2F615FA50081DE16 /* MockPGPInterface.swift in Sources */,
8AB3AD8D2F615FA50081DE16 /* PGPAgentLowLevelTests.swift in Sources */,
30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */, 30BAC8CB22E3BB6C00438475 /* DictBasedKeychain.swift in Sources */,
DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */, DC6474612D46A8F8004B4BBC /* GitRepositoryTest.swift in Sources */,
A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */, A2699ACF24027D9500F36323 /* PasswordTableEntryTest.swift in Sources */,
30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */, 30FD2F78214D9E0E005E0A92 /* ParserTest.swift in Sources */,
A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */, A2AA934622DE3A8000D79A00 /* PGPAgentTest.swift in Sources */,
8A4716692F5EF56900C7A64D /* AppKeychainTest.swift in Sources */,
30695E2524FAEF2600C9D46E /* GitCredentialTest.swift in Sources */, 30695E2524FAEF2600C9D46E /* GitCredentialTest.swift in Sources */,
30BAC8C622E3BAAF00438475 /* TestBase.swift in Sources */, 30BAC8C622E3BAAF00438475 /* TestBase.swift in Sources */,
30B04860209A5141001013CA /* PasswordTest.swift in Sources */, 30B04860209A5141001013CA /* PasswordTest.swift in Sources */,
@ -1886,11 +1921,10 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements; CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -1904,9 +1938,9 @@
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios.shortcuts; PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios.shortcuts;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.shortcuts"; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -1922,11 +1956,10 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements; CODE_SIGN_ENTITLEMENTS = passShortcuts/PassShortcuts.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -1939,9 +1972,9 @@
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios.shortcuts; PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios.shortcuts;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.shortcuts"; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -1980,7 +2013,6 @@
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 0; CURRENT_PROJECT_VERSION = 0;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
@ -2004,7 +2036,7 @@
MARKETING_VERSION = 0.19.0; MARKETING_VERSION = 0.19.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
OTHER_SWIFT_FLAGS = "-D BETA"; OTHER_SWIFT_FLAGS = "-D BETA";
PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforiosbeta; PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforiosbeta;
PRODUCT_NAME = "Pass Beta"; PRODUCT_NAME = "Pass Beta";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
@ -2022,11 +2054,9 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconBeta; ASSETCATALOG_COMPILER_APPICON_NAME = AppIconBeta;
CODE_SIGN_ENTITLEMENTS = pass/passBeta.entitlements; CODE_SIGN_ENTITLEMENTS = pass/passBeta.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEFINES_MODULE = NO; DEFINES_MODULE = NO;
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2042,8 +2072,6 @@
OTHER_LDFLAGS = "${inherited}"; OTHER_LDFLAGS = "${inherited}";
OTHER_SWIFT_FLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; 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; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2057,7 +2085,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = passTests/Info.plist; INFOPLIST_FILE = passTests/Info.plist;
@ -2086,9 +2114,10 @@
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passExtension/passBetaExtension.entitlements; CODE_SIGN_ENTITLEMENTS = passExtension/passBetaExtension.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2105,8 +2134,7 @@
OTHER_SWIFT_FLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension";
PRODUCT_NAME = passExtension; PRODUCT_NAME = passExtension;
PROVISIONING_PROFILE = "cbd86628-6f3e-40f3-b518-20d2330db545"; PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.find-login-action-extension";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2159,6 +2187,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = QLYN3TZMJW;
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = passKitTests/Info.plist; INFOPLIST_FILE = passKitTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
@ -2186,10 +2215,10 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passBetaAutoFillExtension.entitlements; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passBetaAutoFillExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2204,9 +2233,9 @@
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforiosbeta.auto-fill-credential-extension"; PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforiosbeta.auto-fill-credential-extension";
PRODUCT_NAME = passAutoFillExtension; PRODUCT_NAME = passAutoFillExtension;
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.auto-fill-credential-extension"; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2223,11 +2252,10 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passShortcuts/passBetaShortcuts.entitlements; CODE_SIGN_ENTITLEMENTS = passShortcuts/passBetaShortcuts.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Automatic;
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2241,9 +2269,9 @@
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforiosbeta.shortcuts; PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforiosbeta.shortcuts;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforiosbeta.shortcuts"; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -2259,10 +2287,10 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2277,9 +2305,9 @@
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforios.auto-fill-credential-extension"; PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforios.auto-fill-credential-extension";
PRODUCT_NAME = passAutoFillExtension; PRODUCT_NAME = passAutoFillExtension;
PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.auto-fill-credential-extension"; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2296,10 +2324,10 @@
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements; CODE_SIGN_ENTITLEMENTS = passAutoFillExtension/passAutoFillExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Manual; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@ -2313,9 +2341,9 @@
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "me.mssun.passforios.auto-fill-credential-extension"; PRODUCT_BUNDLE_IDENTIFIER = "org.lysanntranvouez.passforios.auto-fill-credential-extension";
PRODUCT_NAME = passAutoFillExtension; PRODUCT_NAME = passAutoFillExtension;
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.auto-fill-credential-extension"; PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2405,6 +2433,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = QLYN3TZMJW;
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = passKitTests/Info.plist; INFOPLIST_FILE = passKitTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
@ -2429,6 +2458,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = QLYN3TZMJW;
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = passKitTests/Info.plist; INFOPLIST_FILE = passKitTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
@ -2455,9 +2485,10 @@
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements; CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2473,8 +2504,7 @@
OTHER_CFLAGS = "$(inherited)"; OTHER_CFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension";
PRODUCT_NAME = passExtension; PRODUCT_NAME = passExtension;
PROVISIONING_PROFILE = "d25c9029-bca6-4b2d-b04e-4abc9d232740"; PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios.find-login-action-extension";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2491,9 +2521,10 @@
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO; CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = NO;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements; CODE_SIGN_ENTITLEMENTS = passExtension/passExtension.entitlements;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2509,8 +2540,7 @@
OTHER_CFLAGS = "$(inherited)"; OTHER_CFLAGS = "$(inherited)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).find-login-action-extension";
PRODUCT_NAME = passExtension; PRODUCT_NAME = passExtension;
PROVISIONING_PROFILE = "cbd86628-6f3e-40f3-b518-20d2330db545"; PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios.find-login-action-extension";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2524,7 +2554,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = passTests/Info.plist; INFOPLIST_FILE = passTests/Info.plist;
@ -2549,7 +2579,7 @@
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
INFOPLIST_FILE = passTests/Info.plist; INFOPLIST_FILE = passTests/Info.plist;
@ -2600,7 +2630,6 @@
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 0; CURRENT_PROJECT_VERSION = 0;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
@ -2630,7 +2659,7 @@
MARKETING_VERSION = 0.19.0; MARKETING_VERSION = 0.19.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios; PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios;
PRODUCT_NAME = Pass; PRODUCT_NAME = Pass;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
@ -2674,7 +2703,6 @@
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 0; CURRENT_PROJECT_VERSION = 0;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
@ -2697,7 +2725,7 @@
LD_RUNPATH_SEARCH_PATHS = ""; LD_RUNPATH_SEARCH_PATHS = "";
MARKETING_VERSION = 0.19.0; MARKETING_VERSION = 0.19.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
PRODUCT_BUNDLE_IDENTIFIER = me.mssun.passforios; PRODUCT_BUNDLE_IDENTIFIER = org.lysanntranvouez.passforios;
PRODUCT_NAME = Pass; PRODUCT_NAME = Pass;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
@ -2715,11 +2743,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements; CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements;
CODE_SIGN_IDENTITY = "iPhone Distribution"; CODE_SIGN_STYLE = Automatic;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEFINES_MODULE = NO; DEFINES_MODULE = NO;
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2734,8 +2761,7 @@
OTHER_CFLAGS = "$(inherited)"; OTHER_CFLAGS = "$(inherited)";
OTHER_LDFLAGS = "${inherited}"; OTHER_LDFLAGS = "${inherited}";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PROVISIONING_PROFILE = "3c4f599a-ce77-4184-b4c4-edebf09cba3b"; PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "match Development me.mssun.passforios";
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -2748,11 +2774,10 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements; CODE_SIGN_ENTITLEMENTS = pass/pass.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEFINES_MODULE = NO; DEFINES_MODULE = NO;
DEVELOPMENT_TEAM = 4WDM8E95VU; DEVELOPMENT_TEAM = QLYN3TZMJW;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = "$(inherited)"; FRAMEWORK_SEARCH_PATHS = "$(inherited)";
HEADER_SEARCH_PATHS = "$(inherited)"; HEADER_SEARCH_PATHS = "$(inherited)";
@ -2768,8 +2793,7 @@
OTHER_LDFLAGS = "${inherited}"; OTHER_LDFLAGS = "${inherited}";
OTHER_SWIFT_FLAGS = ""; OTHER_SWIFT_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PROVISIONING_PROFILE = "ee6e841d-ef77-4f00-b534-d7f1fd25dc1d"; PROVISIONING_PROFILE_SPECIFIER = "";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore me.mssun.passforios";
STRIP_INSTALLED_PRODUCT = NO; STRIP_INSTALLED_PRODUCT = NO;
SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h"; SWIFT_OBJC_BRIDGING_HEADER = "pass/Helpers/Objective-CBridgingHeader.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;

View file

@ -128,7 +128,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
// alert: cancel or try again // alert: cancel or try again
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
let selectKey = UIAlertAction.selectKey(controller: self) { action in let selectKey = UIAlertAction.selectKey(type: .PRIVATE, controller: self) { action in
self.decryptThenShowPasswordLocalKey(keyID: action.title) self.decryptThenShowPasswordLocalKey(keyID: action.title)
} }
alert.addAction(selectKey) alert.addAction(selectKey)
@ -223,7 +223,7 @@ class PasswordDetailTableViewController: UITableViewController, UIGestureRecogni
SVProgressHUD.dismiss() SVProgressHUD.dismiss()
let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Cannot Edit Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: self)) alert.addAction(UIAlertAction.cancelAndPopView(controller: self))
let selectKey = UIAlertAction.selectKey(controller: self) { action in let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: self) { action in
self.saveEditPassword(password: password, keyID: action.title) self.saveEditPassword(password: password, keyID: action.title)
} }
alert.addAction(selectKey) alert.addAction(selectKey)

View file

@ -89,10 +89,12 @@ class SettingsTableViewController: UITableViewController, UITabBarControllerDele
private func setPGPKeyTableViewCellDetailText() { private func setPGPKeyTableViewCellDetailText() {
var label = "NotSet".localize() var label = "NotSet".localize()
let keyID = (try? PGPAgent.shared.getShortKeyID()) ?? [] var keyIDs = Set<String>((try? PGPAgent.shared.getShortKeyIDs(type: .PRIVATE)) ?? [])
if keyID.count == 1 { keyIDs.formUnion((try? PGPAgent.shared.getShortKeyIDs(type: .PUBLIC)) ?? [])
label = keyID.first ?? ""
} else if keyID.count > 1 { if keyIDs.count == 1 {
label = keyIDs.first ?? ""
} else if keyIDs.count > 1 {
label = "Multiple" label = "Multiple"
} }
if Defaults.isYubiKeyEnabled { if Defaults.isYubiKeyEnabled {

View file

@ -30,8 +30,11 @@ func decryptPassword(
} }
DispatchQueue.global(qos: .userInteractive).async { DispatchQueue.global(qos: .userInteractive).async {
do { do {
guard let passwordEntity = PasswordStore.shared.fetchPasswordEntity(with: passwordPath) else {
throw AppError.decryption
}
let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller) let requestPGPKeyPassphrase = Utils.createRequestPGPKeyPassphraseHandler(controller: controller)
let decryptedPassword = try PasswordStore.shared.decrypt(path: passwordPath, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) let decryptedPassword = try PasswordStore.shared.decrypt(passwordEntity: passwordEntity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
DispatchQueue.main.async { DispatchQueue.main.async {
completion(decryptedPassword) completion(decryptedPassword)
@ -40,7 +43,7 @@ func decryptPassword(
DispatchQueue.main.async { DispatchQueue.main.async {
let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "CannotShowPassword".localize(), message: AppError.pgpPrivateKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
let selectKey = UIAlertAction.selectKey(controller: controller) { action in let selectKey = UIAlertAction.selectKey(type: PGPKey.PRIVATE, controller: controller) { action in
decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion) decryptPassword(in: controller, with: passwordPath, using: action.title, completion: completion)
} }
alert.addAction(selectKey) alert.addAction(selectKey)

View file

@ -19,7 +19,7 @@ func encryptPassword(in controller: UIViewController, with password: Password, k
DispatchQueue.main.async { DispatchQueue.main.async {
let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Cannot Encrypt Password", message: AppError.pgpPublicKeyNotFound(keyID: key).localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction.cancelAndPopView(controller: controller)) alert.addAction(UIAlertAction.cancelAndPopView(controller: controller))
let selectKey = UIAlertAction.selectKey(controller: controller) { action in let selectKey = UIAlertAction.selectKey(type: .PUBLIC, controller: controller) { action in
encryptPassword(in: controller, with: password, keyID: action.title, completion: completion) encryptPassword(in: controller, with: password, keyID: action.title, completion: completion)
} }
alert.addAction(selectKey) alert.addAction(selectKey)

View file

@ -72,6 +72,7 @@
"KeyImportError." = "Schlüssel kann nicht importiert werden."; "KeyImportError." = "Schlüssel kann nicht importiert werden.";
"FileNotFoundError." = "Die Datei '%@' kann nicht gelesen werden."; "FileNotFoundError." = "Die Datei '%@' kann nicht gelesen werden.";
"PasswordDuplicatedError." = "Passwort kann nicht hinzugefügt werden; es existiert bereits."; "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."; "GitResetError." = "Der zuletzt synchronisierte Commit kann nicht identifiziert werden.";
"GitCreateSignatureError." = "Es konnte keine valide Signatur für den Author/Committer angelegt 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."; "GitPushNotSuccessfulError." = "Die Übertragung der lokalen Änderungen war nicht erfolgreich. Stelle bitte sicher, dass auf dem Remote-Repository alle Änderungen commitet sind.";

View file

@ -73,6 +73,7 @@
"KeyImportError." = "Cannot import the key."; "KeyImportError." = "Cannot import the key.";
"FileNotFoundError." = "File '%@' cannot be read."; "FileNotFoundError." = "File '%@' cannot be read.";
"PasswordDuplicatedError." = "Cannot add the password; password is duplicated."; "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."; "GitResetError." = "Cannot identify the latest synced commit.";
"GitCreateSignatureError." = "Cannot create a valid author/committer signature."; "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."; "GitPushNotSuccessfulError." = "Pushing local changes was not successful. Make sure there are no uncommitted changes on the remote repository.";

View file

@ -2,21 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>TAG</string>
</array>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforios</string> <string>group.org.lysanntranvouez.passforios</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforios</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforios</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -2,21 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>TAG</string>
</array>
<key>com.apple.developer.siri</key>
<true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforiosbeta</string> <string>group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforiosbeta</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -2,15 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforios</string> <string>group.org.lysanntranvouez.passforios</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforios</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforios</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -2,15 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforiosbeta</string> <string>group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforiosbeta</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -4,11 +4,11 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforiosbeta</string> <string>group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforiosbeta</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -4,11 +4,11 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforios</string> <string>group.org.lysanntranvouez.passforios</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforios</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforios</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -1,5 +1,5 @@
// //
// CoreDataStack.swift // PersistenceController.swift
// passKit // passKit
// //
// Created by Mingshen Sun on 12/28/24. // Created by Mingshen Sun on 12/28/24.
@ -18,19 +18,19 @@ public class PersistenceController {
let container: NSPersistentContainer let container: NSPersistentContainer
init(isUnitTest: Bool = false) { init(storeURL: URL? = nil) {
self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel) self.container = NSPersistentContainer(name: Self.modelName, managedObjectModel: .sharedModel)
let description = container.persistentStoreDescriptions.first let description = container.persistentStoreDescriptions.first
description?.shouldMigrateStoreAutomatically = false description?.shouldMigrateStoreAutomatically = false
description?.shouldInferMappingModelAutomatically = false description?.shouldInferMappingModelAutomatically = false
if isUnitTest { description?.url = storeURL ?? URL(fileURLWithPath: Globals.dbPath)
description?.url = URL(fileURLWithPath: "/dev/null")
} else {
description?.url = URL(fileURLWithPath: Globals.dbPath)
}
setup() setup()
} }
static func forUnitTests() -> PersistenceController {
PersistenceController(storeURL: URL(fileURLWithPath: "/dev/null"))
}
func setup() { func setup() {
container.loadPersistentStores { _, error in container.loadPersistentStores { _, error in
if error != nil { if error != nil {

View file

@ -16,6 +16,7 @@ struct GopenPGPInterface: PGPInterface {
private var publicKeys: [String: CryptoKey] = [:] private var publicKeys: [String: CryptoKey] = [:]
private var privateKeys: [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 { init(publicArmoredKey: String, privateArmoredKey: String) throws {
let pubKeys = extractKeysFromArmored(str: publicArmoredKey) let pubKeys = extractKeysFromArmored(str: publicArmoredKey)
@ -40,7 +41,24 @@ struct GopenPGPInterface: PGPInterface {
} }
throw AppError.keyImport throw AppError.keyImport
} }
privateKeys[cryptoKey.getFingerprint().lowercased()] = cryptoKey
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
}
} }
} }
@ -70,15 +88,13 @@ struct GopenPGPInterface: PGPInterface {
privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) } privateKeys.keys.contains { key in key.hasSuffix(keyID.lowercased()) }
} }
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? { func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
let key: CryptoKey? = { let message = createPGPMessage(from: encryptedData)
if let keyID { guard let message else {
return privateKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value throw AppError.decryption
} }
return privateKeys.first?.value
}()
guard let privateKey = key else { guard let privateKey: CryptoKey = try findDecryptionKey(message: message, keyIDHint: keyIDHint) else {
throw AppError.decryption throw AppError.decryption
} }
@ -87,6 +103,7 @@ struct GopenPGPInterface: PGPInterface {
try privateKey.isLocked(&isLocked) try privateKey.isLocked(&isLocked)
var unlockedKey: CryptoKey! var unlockedKey: CryptoKey!
if isLocked.boolValue { if isLocked.boolValue {
let passphrase = passPhraseForKey(privateKey.getFingerprint())
unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8)) unlockedKey = try privateKey.unlock(passphrase.data(using: .utf8))
} else { } else {
unlockedKey = privateKey unlockedKey = privateKey
@ -100,33 +117,43 @@ struct GopenPGPInterface: PGPInterface {
throw AppError.decryption throw AppError.decryption
} }
let message = createPGPMessage(from: encryptedData)
return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data return try keyRing.decrypt(message, verifyKey: nil, verifyTime: 0).data
} catch { } catch {
throw Self.errorMapping[error.localizedDescription, default: error] throw Self.errorMapping[error.localizedDescription, default: error]
} }
} }
func encrypt(plainData: Data, keyID: String?) throws -> Data { func encryptWithAllKeys(plainData: Data) throws -> Data {
let key: CryptoKey? = { let keyIDs = publicKeys.keys.filter { key in privateKeys.keys.contains(key) }
if let keyID { return try encrypt(plainData: plainData, keyIDs: keyIDs)
return publicKeys.first(where: { key, _ in key.hasSuffix(keyID.lowercased()) })?.value }
}
return publicKeys.first?.value
}()
guard let publicKey = key else { 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)
}
return key
}
guard let firstKey = keys.first else {
throw AppError.encryption throw AppError.encryption
} }
let otherKeys = keys.dropFirst()
var error: NSError? var error: NSError?
guard let keyRing = CryptoNewKeyRing(firstKey, &error) else {
guard let keyRing = CryptoNewKeyRing(publicKey, &error) else {
guard error == nil else { guard error == nil else {
throw error! throw error!
} }
throw AppError.encryption 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) let encryptedData = try keyRing.encrypt(CryptoNewPlainMessage(plainData.mutable as Data), privateKey: nil)
if Defaults.encryptInArmored { if Defaults.encryptInArmored {
@ -140,12 +167,44 @@ struct GopenPGPInterface: PGPInterface {
return encryptedData.getBinary()! return encryptedData.getBinary()!
} }
var keyID: [String] { func getKeyIDs(type: PGPKey) -> [String] {
publicKeys.keys.map { $0.uppercased() } switch type {
case .PUBLIC:
return publicKeys.keys.map { $0.uppercased() }
case .PRIVATE:
return privateKeys.keys.map { $0.uppercased() }
}
} }
var shortKeyID: [String] { func getShortKeyIDs(type: PGPKey) -> [String] {
publicKeys.keys.map { $0.suffix(8).uppercased() } getKeyIDs(type: type).map { $0.suffix(8).uppercased() }
}
private func findDecryptionKey(message: CryptoPGPMessage, keyIDHint: String?) throws -> CryptoKey? {
var keyIDCandidates: any Collection<String> = 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]
} }
} }

View file

@ -24,12 +24,29 @@ struct ObjectivePGPInterface: PGPInterface {
} }
} }
func decrypt(encryptedData: Data, keyID _: String?, passphrase: String) throws -> Data? { func decrypt(encryptedData: Data, keyIDHint _: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data? {
try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { _ in passphrase } try ObjectivePGP.decrypt(encryptedData, andVerifySignature: false, using: keyring.keys) { selectedKey in
guard let selectedKey else {
return nil
}
return passPhraseForKey(selectedKey.keyID.longIdentifier)
}
} }
func encrypt(plainData: Data, keyID _: String?) throws -> Data { func encryptWithAllKeys(plainData: Data) throws -> Data {
let encryptedData = try ObjectivePGP.encrypt(plainData, addSignature: false, using: keyring.keys, passphraseForKey: nil) 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)
if Defaults.encryptInArmored { if Defaults.encryptInArmored {
return Armor.armored(encryptedData, as: .message).data(using: .ascii)! return Armor.armored(encryptedData, as: .message).data(using: .ascii)!
} }
@ -44,11 +61,20 @@ struct ObjectivePGPInterface: PGPInterface {
keyring.findKey(keyID)?.isSecret ?? false keyring.findKey(keyID)?.isSecret ?? false
} }
var keyID: [String] { func getKeyIDs(type: PGPKey) -> [String] {
keyring.keys.map(\.keyID.longIdentifier) getKeys(type: type).map(\.keyID.longIdentifier)
} }
var shortKeyID: [String] { func getShortKeyIDs(type: PGPKey) -> [String] {
keyring.keys.map(\.keyID.shortIdentifier) 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)
}
} }
} }

View file

@ -9,7 +9,7 @@
public class PGPAgent { public class PGPAgent {
public static let shared = PGPAgent() public static let shared = PGPAgent()
private let keyStore: KeyStore let keyStore: KeyStore
private var pgpInterface: PGPInterface? private var pgpInterface: PGPInterface?
private var latestDecryptStatus = true private var latestDecryptStatus = true
@ -17,6 +17,11 @@ public class PGPAgent {
self.keyStore = keyStore self.keyStore = keyStore
} }
init(keyStore: KeyStore, pgpInterface: PGPInterface) {
self.keyStore = keyStore
self.pgpInterface = pgpInterface
}
public func initKeys() throws { public func initKeys() throws {
guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()), guard let publicKey: String = keyStore.get(for: PGPKey.PUBLIC.getKeychainKey()),
let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else { let privateKey: String = keyStore.get(for: PGPKey.PRIVATE.getKeychainKey()) else {
@ -34,30 +39,28 @@ public class PGPAgent {
pgpInterface = nil pgpInterface = nil
} }
public func getKeyID() throws -> [String] { public func isInitialized() -> Bool {
try checkAndInit() pgpInterface != nil
return pgpInterface?.keyID ?? []
} }
public func getShortKeyID() throws -> [String] { public func getKeyIDs(type: PGPKey) throws -> [String] {
try checkAndInit() try checkAndInit()
return pgpInterface?.shortKeyID.sorted() ?? [] return pgpInterface?.getKeyIDs(type: type).sorted() ?? []
} }
public func decrypt(encryptedData: Data, keyID: String, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? { public func getShortKeyIDs(type: PGPKey) throws -> [String] {
// Init keys. try checkAndInit()
return pgpInterface?.getShortKeyIDs(type: type).sorted() ?? []
}
public func decrypt(encryptedData: Data, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Data? {
try checkAndInit() try checkAndInit()
guard let pgpInterface else { guard let pgpInterface else {
throw AppError.decryption throw AppError.decryption
} }
var keyID = keyID if let keyID, !pgpInterface.containsPrivateKey(with: keyID) {
if !pgpInterface.containsPrivateKey(with: keyID) { throw AppError.pgpPrivateKeyNotFound(keyID: 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 // Remember the previous status and set the current status
@ -65,52 +68,14 @@ public class PGPAgent {
latestDecryptStatus = false latestDecryptStatus = false
// Get the PGP key passphrase. // Get the PGP key passphrase.
var passphrase = "" let providePassPhraseForKey = { (selectedKeyID: String) -> String in
if previousDecryptStatus == false { if previousDecryptStatus == false {
passphrase = requestPGPKeyPassphrase(keyID) return requestPGPKeyPassphrase(selectedKeyID)
} else {
passphrase = keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: keyID)) ?? requestPGPKeyPassphrase(keyID)
}
// Decrypt.
guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyID: keyID, passphrase: passphrase) else {
return nil
}
// The decryption step has succeed.
latestDecryptStatus = true
return result
}
public func encrypt(plainData: Data, keyID: String) throws -> Data {
try checkAndInit()
guard let pgpInterface else {
throw AppError.encryption
}
var keyID = keyID
if !pgpInterface.containsPublicKey(with: keyID) {
if pgpInterface.keyID.count == 1 {
keyID = pgpInterface.keyID.first!
} else {
throw AppError.pgpPublicKeyNotFound(keyID: keyID)
} }
} return self.keyStore.get(for: AppKeychain.getPGPKeyPassphraseKey(keyID: selectedKeyID)) ?? requestPGPKeyPassphrase(selectedKeyID)
return try pgpInterface.encrypt(plainData: plainData, keyID: keyID)
}
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. // Decrypt.
guard let result = try pgpInterface!.decrypt(encryptedData: encryptedData, keyID: nil, passphrase: passphrase) else { guard let result = try pgpInterface.decrypt(encryptedData: encryptedData, keyIDHint: keyID, passPhraseForKey: providePassPhraseForKey) else {
return nil return nil
} }
// The decryption step has succeed. // The decryption step has succeed.
@ -118,12 +83,20 @@ public class PGPAgent {
return result return result
} }
public func encrypt(plainData: Data) throws -> Data { public func encrypt(plainData: Data, keyIDs: [String]) throws -> Data {
try checkAndInit() try checkAndInit()
guard let pgpInterface else { guard let pgpInterface else {
throw AppError.encryption throw AppError.encryption
} }
return try pgpInterface.encrypt(plainData: plainData, keyID: nil) return try pgpInterface.encrypt(plainData: plainData, keyIDs: keyIDs)
}
public func encryptWithAllKeys(plainData: Data) throws -> Data {
try checkAndInit()
guard let pgpInterface else {
throw AppError.encryption
}
return try pgpInterface.encryptWithAllKeys(plainData: plainData)
} }
public var isPrepared: Bool { public var isPrepared: Bool {

View file

@ -7,15 +7,15 @@
// //
protocol PGPInterface { protocol PGPInterface {
func decrypt(encryptedData: Data, keyID: String?, passphrase: String) throws -> Data? func decrypt(encryptedData: Data, keyIDHint: String?, passPhraseForKey: @escaping (String) -> String) throws -> Data?
func encrypt(plainData: Data, keyID: 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 containsPublicKey(with keyID: String) -> Bool func containsPublicKey(with keyID: String) -> Bool
func containsPrivateKey(with keyID: String) -> Bool func containsPrivateKey(with keyID: String) -> Bool
var keyID: [String] { get } func getKeyIDs(type: PGPKey) -> [String]
func getShortKeyIDs(type: PGPKey) -> [String]
var shortKeyID: [String] { get }
} }

View file

@ -38,10 +38,10 @@ public extension UIAlertAction {
} }
} }
static func selectKey(controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction { static func selectKey(type: PGPKey, controller: UIViewController, handler: ((UIAlertAction) -> Void)?) -> UIAlertAction {
UIAlertAction(title: "Select Key", style: .default) { _ in UIAlertAction(title: "Select Key", style: .default) { _ in
let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet) let selectKeyAlert = UIAlertController(title: "Select from imported keys", message: nil, preferredStyle: .actionSheet)
try? PGPAgent.shared.getShortKeyID().forEach { keyID in try? PGPAgent.shared.getShortKeyIDs(type: type).forEach { keyID in
let action = UIAlertAction(title: keyID, style: .default, handler: handler) let action = UIAlertAction(title: keyID, style: .default, handler: handler)
selectKeyAlert.addAction(action) selectKeyAlert.addAction(action)
} }

View file

@ -15,6 +15,7 @@ public enum AppError: Error, Equatable {
case keyImport case keyImport
case readingFile(fileName: String) case readingFile(fileName: String)
case passwordDuplicated case passwordDuplicated
case cannotDeleteNonEmptyDirectory
case gitReset case gitReset
case gitCommit case gitCommit
case gitCreateSignature case gitCreateSignature

View file

@ -12,9 +12,9 @@ import UIKit
public final class Globals { public final class Globals {
public static let bundleIdentifier: String = { public static let bundleIdentifier: String = {
#if BETA #if BETA
return "me.mssun.passforiosbeta" return "org.lysanntranvouez.passforiosbeta"
#else #else
return "me.mssun.passforios" return "org.lysanntranvouez.passforios"
#endif #endif
}() }()

View file

@ -23,6 +23,7 @@ public class PasswordStore {
}() }()
public var storeURL: URL public var storeURL: URL
private let pgpAgent: PGPAgent
public var gitRepository: GitRepository? public var gitRepository: GitRepository?
@ -84,8 +85,9 @@ public class PasswordStore {
gitRepository?.numberOfCommits() gitRepository?.numberOfCommits()
} }
init(url: URL = Globals.repositoryURL) { init(url: URL = Globals.repositoryURL, pgpAgent: PGPAgent = .shared) {
self.storeURL = url self.storeURL = url
self.pgpAgent = pgpAgent
// Migration // Migration
importExistingKeysIntoKeychain() importExistingKeysIntoKeychain()
@ -273,9 +275,15 @@ public class PasswordStore {
} }
public func delete(passwordEntity: PasswordEntity) throws { public func delete(passwordEntity: PasswordEntity) throws {
if !passwordEntity.children.isEmpty {
throw AppError.cannotDeleteNonEmptyDirectory
}
let deletedFileURL = passwordEntity.fileURL(in: storeURL) let deletedFileURL = passwordEntity.fileURL(in: storeURL)
let deletedFilePath = passwordEntity.path let deletedFilePath = passwordEntity.path
try gitRm(path: passwordEntity.path) if !passwordEntity.isDir {
try gitRm(path: passwordEntity.path)
}
try deletePasswordEntities(passwordEntity: passwordEntity) try deletePasswordEntities(passwordEntity: passwordEntity)
try deleteDirectoryTree(at: deletedFileURL) try deleteDirectoryTree(at: deletedFileURL)
try gitCommit(message: "RemovePassword.".localize(deletedFilePath)) try gitCommit(message: "RemovePassword.".localize(deletedFilePath))
@ -283,6 +291,11 @@ public class PasswordStore {
} }
public func edit(passwordEntity: PasswordEntity, password: Password, keyID: String? = nil) throws -> PasswordEntity? { 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 var newPasswordEntity: PasswordEntity? = passwordEntity
let url = passwordEntity.fileURL(in: storeURL) let url = passwordEntity.fileURL(in: storeURL)
@ -320,11 +333,11 @@ public class PasswordStore {
saveUpdatedContext() saveUpdatedContext()
} }
public func saveUpdatedContext() { private func saveUpdatedContext() {
PersistenceController.shared.save() PersistenceController.shared.save()
} }
public func deleteCoreData() { private func deleteCoreData() {
PasswordEntity.deleteAll(in: context) PasswordEntity.deleteAll(in: context)
PersistenceController.shared.save() PersistenceController.shared.save()
} }
@ -348,14 +361,14 @@ public class PasswordStore {
eraseStoreData() eraseStoreData()
// Delete PGP key, SSH key and other secrets from the keychain. // Delete PGP key, SSH key and other secrets from the keychain.
AppKeychain.shared.removeAllContent() pgpAgent.keyStore.removeAllContent()
// Delete default settings. // Delete default settings.
Defaults.removeAll() Defaults.removeAll()
// Delete cache explicitly. // Delete cache explicitly.
PasscodeLock.shared.delete() PasscodeLock.shared.delete()
PGPAgent.shared.uninitKeys() pgpAgent.uninitKeys()
} }
// return the number of discarded commits // return the number of discarded commits
@ -384,13 +397,7 @@ public class PasswordStore {
public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password { public func decrypt(passwordEntity: PasswordEntity, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
let url = passwordEntity.fileURL(in: storeURL) let url = passwordEntity.fileURL(in: storeURL)
let encryptedData = try Data(contentsOf: url) let encryptedData = try Data(contentsOf: url)
let data: Data? = try { let data: Data? = try pgpAgent.decrypt(encryptedData: encryptedData, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
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 { guard let decryptedData = data else {
throw AppError.decryption throw AppError.decryption
} }
@ -398,23 +405,20 @@ public class PasswordStore {
return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText) return Password(name: passwordEntity.name, path: passwordEntity.path, plainText: plainText)
} }
public func decrypt(path: String, keyID: String? = nil, requestPGPKeyPassphrase: @escaping (String) -> String) throws -> Password {
guard let passwordEntity = fetchPasswordEntity(with: path) else {
throw AppError.decryption
}
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 { public func encrypt(password: Password, keyID: String? = nil) throws -> Data {
let encryptedDataPath = password.fileURL(in: storeURL) let keyIDs: [String] = {
let keyID = keyID ?? findGPGID(from: encryptedDataPath) if let keyID {
if Defaults.isEnableGPGIDOn { return [keyID]
return try PGPAgent.shared.encrypt(plainData: password.plainData, keyID: keyID) }
if Defaults.isEnableGPGIDOn {
return findGPGIDs(underPath: password.path)
}
return []
}()
if !keyIDs.isEmpty {
return try pgpAgent.encrypt(plainData: password.plainData, keyIDs: keyIDs)
} }
return try PGPAgent.shared.encrypt(plainData: password.plainData) return try pgpAgent.encryptWithAllKeys(plainData: password.plainData)
} }
public func removeGitSSHKeys() { public func removeGitSSHKeys() {
@ -425,6 +429,37 @@ public class PasswordStore {
AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey()) AppKeychain.shared.removeContent(for: SSHKey.PRIVATE.getKeychainKey())
gitSSHPrivateKeyPassphrase = nil 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 { extension PasswordStore {
@ -457,14 +492,3 @@ extension PasswordStore {
return try gitRepository.commit(signature: gitSignatureForNow, message: message) 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 ?? ""
}

View file

@ -0,0 +1,93 @@
//
// 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...
}
}

View file

@ -20,7 +20,7 @@ class CoreDataTestCase: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
try super.setUpWithError() try super.setUpWithError()
controller = PersistenceController(isUnitTest: true) controller = PersistenceController.forUnitTests()
} }
override func tearDown() { override func tearDown() {

View file

@ -85,4 +85,99 @@ final class PasswordEntityTest: CoreDataTestCase {
XCTAssertEqual(PasswordEntity.fetchAll(in: context).count, 0) 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)
}
} }

View file

@ -31,34 +31,7 @@ final class PGPAgentTest: XCTestCase {
super.tearDown() super.tearDown()
} }
private func basicEncryptDecrypt(using pgpAgent: PGPAgent, keyID: String, encryptKeyID: String? = nil, requestPassphrase: @escaping (String) -> String = requestPGPKeyPassphrase, encryptInArmored: Bool = true, decryptFromArmored: Bool = true) throws -> Data? { // - MARK: Basic encrypt and decrypt tests
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 { func testBasicEncryptDecrypt() throws {
try [ try [
@ -75,7 +48,8 @@ final class PGPAgentTest: XCTestCase {
try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey) try importKeys(testKeyInfo.publicKey, testKeyInfo.privateKey)
XCTAssert(pgpAgent.isPrepared) XCTAssert(pgpAgent.isPrepared)
try pgpAgent.initKeys() try pgpAgent.initKeys()
XCTAssert(try pgpAgent.getKeyID().first!.lowercased().hasSuffix(testKeyInfo.fingerprint)) XCTAssert(try pgpAgent.getKeyIDs(type: .PUBLIC).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
XCTAssert(try pgpAgent.getKeyIDs(type: .PRIVATE).first!.lowercased().hasSuffix(testKeyInfo.fingerprint))
try [ try [
(true, true), (true, true),
(true, false), (true, false),
@ -161,8 +135,116 @@ final class PGPAgentTest: XCTestCase {
XCTAssertEqual(passphraseRequestCalledCount, 3) 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 { private func importKeys(_ publicKey: String, _ privateKey: String) throws {
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey) try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: publicKey)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: privateKey) 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)
}
} }

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -0,0 +1,6 @@
# 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]
# *~

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
fbcbb5819e1c864ef33cfffa179a71387a5d90d0 refs/heads/main

View file

@ -0,0 +1 @@
ref: refs/heads/main

View file

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
ignorecase = true
precomposeunicode = true

View file

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View file

@ -0,0 +1,6 @@
# 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]
# *~

View file

@ -0,0 +1,3 @@
x•ÍM
Â0@a×9Åì™ü<E284A2>¤ â\z<>i:Å@zzKÕ¸}ï¥ež³€îôN*3Sôv¤48J=ÙÞEoØŽÎDÒˆÈk"EMnK…ËóN¥ÀµRy,<2C>_pœ¶r_9p;<3B>Á¢‹ˆìqETÚ¾Âÿ
&ú¯ i¥ÞÃ

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
f095bb4897e4cd58faadfe4d4f678fb697be3ffd refs/heads/main

View file

@ -0,0 +1 @@
925eb0f6b19282b5f10dfe008e0062b4be6dd41a not-for-merge branch 'master' of https://github.com/mssun/passforios-password-store

View file

@ -0,0 +1 @@
ref: refs/heads/master

View file

@ -0,0 +1,9 @@
[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/*

View file

@ -0,0 +1 @@
Example password store repository for passforios tests with .gpg-id files.

View file

@ -0,0 +1,6 @@
# 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]
# *~

View file

@ -0,0 +1 @@
xÌ1 @k^áBˆI(9BíŒÿïÔj»]÷³ölEUM×™C{ÏlDˆ+=r¢ŠÚKd¬8.4†u Ùy: nç—ˆKyº1F

View file

@ -0,0 +1,3 @@
x<01>ŽAjÄ0sÖ+æbF#Y¶ „< Ç|@òŒm]ÙHrÀy}ÌB><3E>C_
º»¦í~O ÒS+"@ÆÚyÔÚ…h}`ƒÌÞ±'1èEGãE­öP$7ðÔKÄÙEíi¤ØÏyÄñŠ£h£8f«ƒ
G[·g 9Ãg ù{;ä^oòÞþH'Çèa0d{D ϨÕôðlòÿU×K™<4B>S<EFBFBD>5T ø³BÊÐ-ûò’BfH­ÂÚzÄ9ÝRÉS9÷vÕã qk«úõcJ

View file

@ -0,0 +1,2 @@
# pack-refs with: peeled fully-peeled sorted
925eb0f6b19282b5f10dfe008e0062b4be6dd41a refs/heads/master

View file

@ -0,0 +1 @@
c7c52ac6962d08d69e5651eedd6cbaf2f8bd05c3

View file

@ -0,0 +1 @@
925eb0f6b19282b5f10dfe008e0062b4be6dd41a

View file

@ -0,0 +1,101 @@
//
// 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")
}
}

View file

@ -0,0 +1,414 @@
//
// 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)
}
}
}

View file

@ -0,0 +1,101 @@
//
// MockPGPInterface.swift
// passKitTests
//
import Foundation
@testable import passKit
class MockPGPInterface: PGPInterface {
// MARK: - Configuration
var publicKeyIDs: Set<String> = []
var privateKeyIDs: Set<String> = []
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
[]
}
}

View file

@ -13,47 +13,622 @@ import XCTest
@testable import passKit @testable import passKit
final class PasswordStoreTest: XCTestCase { final class PasswordStoreTest: XCTestCase {
private let remoteRepoURL = URL(string: "https://github.com/mssun/passforios-password-store.git")! private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/")
func testCloneAndDecryptMultiKeys() throws { private var keyStore: KeyStore! = nil
let url = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var pgpAgent: PGPAgent! = nil
private var passwordStore: PasswordStore! = nil
Defaults.isEnableGPGIDOn = true override func setUp() {
let passwordStore = PasswordStore(url: url) super.setUp()
try passwordStore.cloneRepository(remoteRepoURL: remoteRepoURL, branchName: "master")
expectation(for: NSPredicate { _, _ in FileManager.default.fileExists(atPath: url.path) }, evaluatedWith: nil)
waitForExpectations(timeout: 3, handler: nil)
[ keyStore = DictBasedKeychain()
("work/github.com", "4712286271220DB299883EA7062E678DA1024DAE"), pgpAgent = PGPAgent(keyStore: keyStore)
("personal/github.com", "787EAE1A5FA3E749AA34CC6AA0645EBED862027E"), passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent)
].forEach { path, id in }
let keyID = findGPGID(from: url.appendingPathComponent(path))
XCTAssertEqual(keyID, id) 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 keychain = AppKeychain.shared let personalDir = passwordStore.fetchPasswordEntity(with: "personal")
try KeyFileManager(keyType: PGPKey.PUBLIC, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.publicKeys) let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir)
try KeyFileManager(keyType: PGPKey.PRIVATE, keyPath: "", keyHandler: keychain.add).importKey(from: RSA2048_RSA4096.privateKeys) XCTAssertEqual(personalChildren.count, 1)
try PGPAgent.shared.initKeys() XCTAssertEqual(personalChildren.first?.name, "github.com")
}
let personal = try decrypt(passwordStore: passwordStore, path: "personal/github.com.gpg", passphrase: "passforios") func testFetchPasswordEntityCoreDataWithDir() throws {
XCTAssertEqual(personal.plainText, "passwordforpersonal\n") try cloneRepository(.withGPGID)
let work = try decrypt(passwordStore: passwordStore, path: "work/github.com.gpg", passphrase: "passforios") let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false)
XCTAssertEqual(work.plainText, "passwordforwork\n") 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 testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword") let testPassword = Password(name: "test", path: "test.gpg", plainText: "testpassword")
let testPasswordEntity = try passwordStore.add(password: testPassword)! _ = try passwordStore.add(password: testPassword)
let testPasswordPlain = try passwordStore.decrypt(passwordEntity: testPasswordEntity, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
XCTAssertEqual(testPasswordPlain.plainText, "testpassword")
passwordStore.erase() XCTAssertEqual(mockPGPInterface.encryptMultiKeyCalls.count, 1)
Defaults.isEnableGPGIDOn = false let encryptCall = mockPGPInterface.encryptMultiKeyCalls.first
XCTAssertEqual(encryptCall?.plainData, testPassword.plainData)
XCTAssertEqual(encryptCall?.keyIDs, [RSA4096.longFingerprint].map { $0.uppercased() })
} }
private func decrypt(passwordStore: PasswordStore, path: String, passphrase _: String) throws -> Password { 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
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 {
let entity = passwordStore.fetchPasswordEntity(with: path)! let entity = passwordStore.fetchPasswordEntity(with: path)!
return try passwordStore.decrypt(passwordEntity: entity, requestPGPKeyPassphrase: requestPGPKeyPassphrase) return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase)
} }
} }

View file

@ -16,6 +16,7 @@ struct PGPTestSet {
let publicKey: String let publicKey: String
let privateKey: String let privateKey: String
let fingerprint: String let fingerprint: String
let longFingerprint: String
let passphrase: String let passphrase: String
fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate fileprivate func collect() -> Self { // swiftlint:disable:this strict_fileprivate
@ -28,6 +29,7 @@ struct MultiKeyPGPTestSet {
let publicKeys: String let publicKeys: String
let privateKeys: String let privateKeys: String
let fingerprints: [String] let fingerprints: [String]
let longFingerprints: [String]
let passphrases: [String] let passphrases: [String]
} }
@ -35,6 +37,7 @@ let RSA2048 = PGPTestSet(
publicKey: PGP_RSA2048_PUBLIC_KEY, publicKey: PGP_RSA2048_PUBLIC_KEY,
privateKey: PGP_RSA2048_PRIVATE_KEY, privateKey: PGP_RSA2048_PRIVATE_KEY,
fingerprint: "a1024dae", fingerprint: "a1024dae",
longFingerprint: "4712286271220db299883ea7062e678da1024dae",
passphrase: "passforios" passphrase: "passforios"
).collect() ).collect()
@ -42,6 +45,7 @@ let RSA2048_SUB = PGPTestSet(
publicKey: PGP_RSA2048_PUBLIC_KEY, publicKey: PGP_RSA2048_PUBLIC_KEY,
privateKey: PGP_RSA2048_PRIVATE_SUBKEY, privateKey: PGP_RSA2048_PRIVATE_SUBKEY,
fingerprint: "a1024dae", fingerprint: "a1024dae",
longFingerprint: "4712286271220db299883ea7062e678da1024dae",
passphrase: "passforios" passphrase: "passforios"
) )
@ -49,6 +53,7 @@ let RSA3072_NO_PASSPHRASE = PGPTestSet(
publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE, publicKey: PGP_RSA3072_PUBLIC_KEY_NO_PASSPHRASE,
privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE, privateKey: PGP_RSA3072_PRIVATE_KEY_NO_PASSPHRASE,
fingerprint: "be0f9402", fingerprint: "be0f9402",
longFingerprint: "b37cd5669a03f0d46735a2ba35fba3d0be0f9402",
passphrase: "" passphrase: ""
) )
@ -56,6 +61,7 @@ let RSA4096 = PGPTestSet(
publicKey: PGP_RSA4096_PUBLIC_KEY, publicKey: PGP_RSA4096_PUBLIC_KEY,
privateKey: PGP_RSA4096_PRIVATE_KEY, privateKey: PGP_RSA4096_PRIVATE_KEY,
fingerprint: "d862027e", fingerprint: "d862027e",
longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e",
passphrase: "passforios" passphrase: "passforios"
).collect() ).collect()
@ -63,6 +69,7 @@ let RSA4096_SUB = PGPTestSet(
publicKey: PGP_RSA4096_PUBLIC_KEY, publicKey: PGP_RSA4096_PUBLIC_KEY,
privateKey: PGP_RSA4096_PRIVATE_SUBKEY, privateKey: PGP_RSA4096_PRIVATE_SUBKEY,
fingerprint: "d862027e", fingerprint: "d862027e",
longFingerprint: "787eae1a5fa3e749aa34cc6aa0645ebed862027e",
passphrase: "passforios" passphrase: "passforios"
) )
@ -70,6 +77,7 @@ let ED25519 = PGPTestSet(
publicKey: PGP_ED25519_PUBLIC_KEY, publicKey: PGP_ED25519_PUBLIC_KEY,
privateKey: PGP_ED25519_PRIVATE_KEY, privateKey: PGP_ED25519_PRIVATE_KEY,
fingerprint: "e9444483", fingerprint: "e9444483",
longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483",
passphrase: "passforios" passphrase: "passforios"
).collect() ).collect()
@ -77,6 +85,7 @@ let ED25519_SUB = PGPTestSet(
publicKey: PGP_ED25519_PUBLIC_KEY, publicKey: PGP_ED25519_PUBLIC_KEY,
privateKey: PGP_ED25519_PRIVATE_SUBKEY, privateKey: PGP_ED25519_PRIVATE_SUBKEY,
fingerprint: "e9444483", fingerprint: "e9444483",
longFingerprint: "5fccb081ab8af48972999e2ae750acbfe9444483",
passphrase: "passforios" passphrase: "passforios"
) )
@ -84,6 +93,7 @@ let NISTP384 = PGPTestSet(
publicKey: PGP_NISTP384_PUBLIC_KEY, publicKey: PGP_NISTP384_PUBLIC_KEY,
privateKey: PGP_NISTP384_PRIVATE_KEY, privateKey: PGP_NISTP384_PRIVATE_KEY,
fingerprint: "5af3c085", fingerprint: "5af3c085",
longFingerprint: "bcd364c078585c0607e19c67171c07d25af3c085",
passphrase: "soirofssap" passphrase: "soirofssap"
).collect() ).collect()
@ -91,6 +101,7 @@ let RSA2048_RSA4096 = MultiKeyPGPTestSet(
publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY, publicKeys: PGP_RSA2048_PUBLIC_KEY | PGP_RSA4096_PUBLIC_KEY,
privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY, privateKeys: PGP_RSA2048_PRIVATE_KEY | PGP_RSA4096_PRIVATE_KEY,
fingerprints: ["a1024dae", "d862027e"], fingerprints: ["a1024dae", "d862027e"],
longFingerprints: ["4712286271220db299883ea7062e678da1024dae", "787eae1a5fa3e749aa34cc6aa0645ebed862027e"],
passphrases: ["passforios", "passforios"] passphrases: ["passforios", "passforios"]
) )
@ -98,6 +109,7 @@ let ED25519_NISTP384 = MultiKeyPGPTestSet(
publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY, publicKeys: PGP_ED25519_PUBLIC_KEY | PGP_NISTP384_PUBLIC_KEY,
privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY, privateKeys: PGP_ED25519_PRIVATE_KEY | PGP_NISTP384_PRIVATE_KEY,
fingerprints: ["e9444483", "5af3c085"], fingerprints: ["e9444483", "5af3c085"],
longFingerprints: ["5fccb081ab8af48972999e2ae750acbfe9444483", "bcd364c078585c0607e19c67171c07d25af3c085"],
passphrases: ["passforios", "soirofssap"] passphrases: ["passforios", "soirofssap"]
) )

View file

@ -4,11 +4,11 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforiosbeta</string> <string>group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforiosbeta</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforiosbeta</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -4,11 +4,11 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.me.mssun.passforios</string> <string>group.org.lysanntranvouez.passforios</string>
</array> </array>
<key>keychain-access-groups</key> <key>keychain-access-groups</key>
<array> <array>
<string>$(AppIdentifierPrefix)group.me.mssun.passforios</string> <string>$(AppIdentifierPrefix)group.org.lysanntranvouez.passforios</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View file

@ -0,0 +1,191 @@
# 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 <email@example.com>"`. 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 <email>" 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 <alice@example.com>"`) 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.

View file

@ -0,0 +1,423 @@
# 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)

View file

@ -14,7 +14,7 @@ GOPENPGP_PATH="$CHECKOUT_PATH/gopenpgp"
mkdir -p "$OUTPUT_PATH" mkdir -p "$OUTPUT_PATH"
mkdir -p "$CHECKOUT_PATH" mkdir -p "$CHECKOUT_PATH"
git clone --depth 1 --branch "$GOPENPGP_VERSION" https://github.com/mssun/gopenpgp.git "$GOPENPGP_PATH" git clone --depth 1 --branch "$GOPENPGP_VERSION" https://forgejo.tranvouez.eu/lysann/passforios-gopenpgp.git "$GOPENPGP_PATH"
pushd "$GOPENPGP_PATH" pushd "$GOPENPGP_PATH"
mkdir -p dist mkdir -p dist