// // PasswordStoreTest.swift // passKitTests // // Created by Mingshen Sun on 13/4/2020. // Copyright © 2020 Bob Sun. All rights reserved. // import Foundation import ObjectiveGit import XCTest @testable import passKit final class PasswordStoreTest: XCTestCase { private let localRepoURL: URL = Globals.sharedContainerURL.appendingPathComponent("Library/password-store-test/") private var keyStore: KeyStore! = nil private var pgpAgent: PGPAgent! = nil private var passwordStore: PasswordStore! = nil override func setUp() { super.setUp() keyStore = DictBasedKeychain() pgpAgent = PGPAgent(keyStore: keyStore) passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent) } private func setUpMockedPGPInterface() -> MockPGPInterface { let mockPGPInterface = MockPGPInterface() keyStore = DictBasedKeychain() pgpAgent = PGPAgent(keyStore: keyStore, pgpInterface: mockPGPInterface) passwordStore = PasswordStore(url: localRepoURL, pgpAgent: pgpAgent) // Set pgpKeyPassphrase key so checkAndInit() doesn't re-init and overwrite our mock. keyStore.add(string: "dummy", for: Globals.pgpKeyPassphrase) return mockPGPInterface } override func tearDown() { passwordStore.erase() passwordStore = nil pgpAgent = nil keyStore = nil Defaults.removeAll() super.tearDown() } func testInitPasswordEntityCoreData() throws { try cloneRepository(.withGPGID) XCTAssertEqual(passwordStore.numberOfPasswords, 4) XCTAssertEqual(passwordStore.numberOfCommits, 17) XCTAssertEqual(passwordStore.numberOfLocalCommits, 0) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") XCTAssertEqual(entity!.path, "personal/github.com.gpg") XCTAssertEqual(entity!.name, "github.com") XCTAssertTrue(entity!.isSynced) XCTAssertEqual(entity!.parent!.name, "personal") XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "family/amazon.com.gpg")) XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "shared/github.com.gpg")) let dirEntity = passwordStore.fetchPasswordEntity(with: "shared") XCTAssertNotNil(dirEntity) XCTAssertTrue(dirEntity!.isDir) XCTAssertEqual(dirEntity!.name, "shared") XCTAssertEqual(dirEntity!.children.count, 1) } func testEraseStoreData() throws { try cloneRepository(.withGPGID) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertNotNil(passwordStore.gitRepository) expectation(forNotification: .passwordStoreUpdated, object: nil) expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.eraseStoreData() XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.path)) XCTAssertEqual(passwordStore.numberOfPasswords, 0) XCTAssertNil(passwordStore.gitRepository) waitForExpectations(timeout: 1, handler: nil) } func testErase() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() Defaults.gitSignatureName = "Test User" PasscodeLock.shared.save(passcode: "1234") XCTAssertGreaterThan(passwordStore.numberOfPasswords, 0) XCTAssertTrue(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertEqual(Defaults.gitSignatureName, "Test User") XCTAssertTrue(PasscodeLock.shared.hasPasscode) XCTAssertTrue(pgpAgent.isInitialized()) expectation(forNotification: .passwordStoreUpdated, object: nil) expectation(forNotification: .passwordStoreErased, object: nil) passwordStore.erase() XCTAssertEqual(passwordStore.numberOfPasswords, 0) XCTAssertFalse(keyStore.contains(key: PGPKey.PUBLIC.getKeychainKey())) XCTAssertFalse(Defaults.hasKey(\.gitSignatureName)) XCTAssertFalse(PasscodeLock.shared.hasPasscode) XCTAssertFalse(pgpAgent.isInitialized()) waitForExpectations(timeout: 1, handler: nil) } func testFetchPasswordEntityCoreDataByParent() throws { try cloneRepository(.withGPGID) let rootChildren = passwordStore.fetchPasswordEntityCoreData(parent: nil) XCTAssertGreaterThan(rootChildren.count, 0) rootChildren.forEach { entity in XCTAssertTrue(entity.isDir) } let personalDir = passwordStore.fetchPasswordEntity(with: "personal") let personalChildren = passwordStore.fetchPasswordEntityCoreData(parent: personalDir) XCTAssertEqual(personalChildren.count, 1) XCTAssertEqual(personalChildren.first?.name, "github.com") } func testFetchPasswordEntityCoreDataWithDir() throws { try cloneRepository(.withGPGID) let allPasswords = passwordStore.fetchPasswordEntityCoreData(withDir: false) XCTAssertEqual(allPasswords.count, 4) allPasswords.forEach { entity in XCTAssertFalse(entity.isDir) } } func testAddPassword() throws { try cloneRepository(.empty) try importSinglePGPKey() let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let password1 = Password(name: "test1", path: "test1.gpg", plainText: "foobar") let password2 = Password(name: "test2", path: "test2.gpg", plainText: "hello world") let password3 = Password(name: "test3", path: "folder/test3.gpg", plainText: "lorem ipsum") let password4 = Password(name: "test4", path: "test4.gpg", plainText: "you are valuable and you matter") for password in [password1, password2, password3, password4] { expectation(forNotification: .passwordStoreUpdated, object: nil) let savedEntity = try passwordStore.add(password: password) XCTAssertEqual(savedEntity!.name, password.name) waitForExpectations(timeout: 1, handler: nil) } XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test1.gpg").path)) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test2.gpg").path)) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder").path)) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("folder/test3.gpg").path)) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test4.gpg").path)) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 4) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 4) } func testAddAndDecryptRoundTrip() throws { try cloneRepository(.empty) try importSinglePGPKey() let password = Password(name: "test", path: "test.gpg", plainText: "foobar") let savedEntity = try passwordStore.add(password: password) let decryptedPassword = try passwordStore.decrypt(passwordEntity: savedEntity!, requestPGPKeyPassphrase: requestPGPKeyPassphrase) XCTAssertEqual(decryptedPassword.plainText, "foobar") } func testDeletePassword() throws { try cloneRepository(.withGPGID) let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil) let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg") try passwordStore.delete(passwordEntity: entity!) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal")) XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal").path)) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testDeletePasswordKeepsFileSystemFolderIfNotEmpty() throws { try cloneRepository(.withGPGID) // /work contains .gpg-id in addition to a password file let entity = passwordStore.fetchPasswordEntity(with: "work/github.com.gpg") try passwordStore.delete(passwordEntity: entity!) XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("work/github.com.gpg").path)) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "work/github.com.gpg")) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "work")) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("work/.gpg-id").path)) } func testDeleteEmptyDirectory() throws { try cloneRepository(.emptyDirs) let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil) // Note: the directory isn't truely empty since Git doesn't track empty directories, // but it should be treated as empty by the app since it contains only hidden files let entityToDelete = passwordStore.fetchPasswordEntity(with: "empty-dir") XCTAssertNotNil(entityToDelete) try passwordStore.delete(passwordEntity: entityToDelete!) XCTAssertNil(passwordStore.fetchPasswordEntity(with: "empty-dir")) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("empty-dir/.gitkeep").path)) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testDeleteNonEmptyDirectoryFails() throws { try cloneRepository(.withGPGID) let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits expectation(forNotification: .passwordStoreUpdated, object: nil).isInverted = true let entity = passwordStore.fetchPasswordEntity(with: "personal") XCTAssertThrowsError(try passwordStore.delete(passwordEntity: entity!)) { error in XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") XCTAssertEqual(error as? AppError, .cannotDeleteNonEmptyDirectory) } XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) waitForExpectations(timeout: 0.1, handler: nil) } func testEditPasswordValue() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) let editedPassword = Password(name: entity.name, path: entity.path, plainText: "editedpassword") editedPassword.changed = PasswordChange.content.rawValue let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) XCTAssertNotNil(editedEntity) XCTAssertEqual(editedEntity!.name, "github.com") XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "personal/github.com.gpg").plainText, "editedpassword") XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testMovePassword() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits let entity = passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")! expectation(forNotification: .passwordStoreUpdated, object: nil) let editedPassword = Password(name: "new name", path: "new name.gpg", plainText: "passwordforpersonal\n") editedPassword.changed = PasswordChange.path.rawValue let editedEntity = try passwordStore.edit(passwordEntity: entity, password: editedPassword) XCTAssertEqual(editedEntity!.name, "new name") XCTAssertFalse(editedEntity!.isSynced) XCTAssertEqual(try decrypt(path: "new name.gpg").plainText, "passwordforpersonal\n") XCTAssertNil(passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore + 1) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore + 1) waitForExpectations(timeout: 1, handler: nil) } func testEditDirectoryFails() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() let numCommitsBefore = passwordStore.numberOfCommits! let directoryEntity = passwordStore.fetchPasswordEntity(with: "personal")! let editedPassword = Password(name: "new name", path: "new name", plainText: "") editedPassword.changed = PasswordChange.path.rawValue XCTAssertThrowsError(try passwordStore.edit(passwordEntity: directoryEntity, password: editedPassword)) { error in XCTAssertTrue(error is AppError, "Unexpected error type: \(type(of: error))") XCTAssertEqual(error as? AppError, .other(message: "Cannot edit a directory")) } XCTAssertNotNil(passwordStore.fetchPasswordEntity(with: "personal")) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) } func testReset() throws { try cloneRepository(.withGPGID) try importSinglePGPKey() let numCommitsBefore = passwordStore.numberOfCommits! let numLocalCommitsBefore = passwordStore.numberOfLocalCommits _ = try passwordStore.add(password: Password(name: "test", path: "test.gpg", plainText: "foobar")) try passwordStore.delete(passwordEntity: passwordStore.fetchPasswordEntity(with: "personal/github.com.gpg")!) expectation(forNotification: .passwordStoreUpdated, object: nil) let numDroppedCommits = try passwordStore.reset() XCTAssertEqual(numDroppedCommits, 2) XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("test.gpg").path)) XCTAssertTrue(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent("personal/github.com.gpg").path)) XCTAssertEqual(passwordStore.numberOfCommits!, numCommitsBefore) XCTAssertEqual(passwordStore.numberOfLocalCommits, numLocalCommitsBefore) waitForExpectations(timeout: 1, handler: nil) } // MARK: - Find .gpg-id func testFindGPGIDFile() throws { try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data("under root".utf8))) try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("foo/bar/baz"), withIntermediateDirectories: true) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path, contents: Data("under foo".utf8))) try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/"), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey"), withIntermediateDirectories: true) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path, contents: Data("under hey".utf8))) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "/")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/..")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/doesnt-exist")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("foo/.gpg-id").path)) // there is a _drectory_ called .gpg-id in here XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "weird-subdir/.gpg-id/hey")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent("weird-subdir/.gpg-id/hey/.gpg-id").path)) // "foo/bar/../../baz" resolves to "baz" which has no .gpg-id, so should find root's. // Without path resolution, the walk ["foo","bar","..","..","baz"] → remove "baz" → remove ".." → // "foo/bar/.." → remove ".." → "foo/bar" → finds foo/.gpg-id (wrong). try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("baz"), withIntermediateDirectories: true) XCTAssertEqual(passwordStore.findGPGIDFile(atPath: "foo/bar/../../baz")?.absoluteURL, URL(fileURLWithPath: localRepoURL.appendingPathComponent(".gpg-id").path)) } func testMissingGPGIDFile() throws { XCTAssertFalse(FileManager.default.fileExists(atPath: localRepoURL.appendingPathComponent(".gpg-id").path)) try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("subdir"), withIntermediateDirectories: true) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "subdir")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "missing")) } func testFindGPGIDFileStopsAtRoot() throws { // Place a .gpg-id file ABOVE the store root, this should not be found let parentDir = localRepoURL.deletingLastPathComponent() let escapedGPGIDURL = parentDir.appendingPathComponent(".gpg-id") XCTAssertTrue(FileManager.default.createFile(atPath: escapedGPGIDURL.path, contents: Data("ESCAPED_KEY".utf8))) defer { try? FileManager.default.removeItem(at: escapedGPGIDURL) } // Store has no .gpg-id at all try FileManager.default.createDirectory(at: localRepoURL.appendingPathComponent("sub/deep"), withIntermediateDirectories: true) // Direct paths, should not find the escaped .gpg-id since it's outside the store root XCTAssertNil(passwordStore.findGPGIDFile(atPath: "")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep")) // Path traversal attempts via ".." XCTAssertNil(passwordStore.findGPGIDFile(atPath: "..")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "../..")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/../..")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../..")) XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/deep/../../../../../etc")) // Symlink escape: create a symlink inside the store pointing outside let evilDir = parentDir.appendingPathComponent("evil") try FileManager.default.createDirectory(at: evilDir, withIntermediateDirectories: true) XCTAssertTrue(FileManager.default.createFile(atPath: evilDir.appendingPathComponent(".gpg-id").path, contents: Data("EVIL_KEY".utf8))) defer { try? FileManager.default.removeItem(at: evilDir) } try FileManager.default.createSymbolicLink(at: localRepoURL.appendingPathComponent("sub/escape"), withDestinationURL: evilDir) // Following the symlink would find evil/.gpg-id — must not happen XCTAssertNil(passwordStore.findGPGIDFile(atPath: "sub/escape")) } // MARK: Parse .gpg-id func testReadGPGIDFile() throws { try cloneRepository(.withGPGID) [ ("", [RSA4096.longFingerprint]), ("family", [String(NISTP384.longFingerprint.suffix(16))]), ("personal", [RSA4096.longFingerprint]), ("shared", [RSA2048.longFingerprint, RSA4096.longFingerprint]), ("work", [RSA2048.longFingerprint]), ].forEach { path, expectedKeyIDs in let foundKeyIDs = passwordStore.findGPGIDs(underPath: path) XCTAssertEqual(foundKeyIDs, expectedKeyIDs.map { $0.uppercased() }, "Failed for path: \(path)") } } func testReadEmptyGPGIDFile() throws { try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil)) XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8))) XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) } func testReadGPGIDFileWithWhitespace() throws { try FileManager.default.createDirectory(at: localRepoURL, withIntermediateDirectories: true) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: nil)) XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \n\t".utf8))) XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), []) XCTAssertTrue(FileManager.default.createFile(atPath: localRepoURL.appendingPathComponent(".gpg-id").path, contents: Data(" \nbar foo\n\tbaz\n \n".utf8))) XCTAssertEqual(passwordStore.findGPGIDs(underPath: ""), ["bar foo", "baz"]) } // MARK: Handle .gpg-id func testAddPasswordInRoot_WithSingleEntryInPGPIDFile_EncryptsWithThatKey() throws { let mockPGPInterface = setUpMockedPGPInterface() mockPGPInterface.publicKeyIDs = Set(RSA2048_RSA4096.fingerprints) try cloneRepository(.withGPGID) Defaults.isEnableGPGIDOn = true let testPassword = Password(name: "test", path: "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 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)! return try passwordStore.decrypt(passwordEntity: entity, keyID: keyID, requestPGPKeyPassphrase: requestPGPKeyPassphrase) } }