diff --git a/docs/superpowers/plans/2026-05-21-mypass.md b/docs/superpowers/plans/2026-05-21-mypass.md new file mode 100644 index 0000000..3f001e2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-mypass.md @@ -0,0 +1,2760 @@ +# MyPass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a KDBX-backed iOS/macOS password manager with biometric unlock and an AutoFill Credential Provider extension that fills credentials into every app on the device. + +**Architecture:** A local Swift Package (`MyPassCore`) holds all vault logic (KDBX parsing, models, TOTP, Keychain). The main app and AutoFill Extension both link to it. A shared App Group exposes the KDBX file bookmark and Keychain credentials to both processes. + +**Tech Stack:** Swift 5.9+, SwiftUI, KeePassKit (KDBX 3.1/4.0 parsing via SPM), CryptoKit (HMAC-SHA for TOTP), LocalAuthentication, AuthenticationServices, Security framework. + +--- + +## File Map + +``` +MyPassCore/ ← new local Swift Package +├── Package.swift +└── Sources/MyPassCore/ +│ ├── Models/ +│ │ ├── KDBXDatabase.swift +│ │ ├── DatabaseMetadata.swift +│ │ ├── Group.swift +│ │ ├── Entry.swift +│ │ ├── ProtectedString.swift +│ │ ├── CustomField.swift +│ │ ├── Attachment.swift +│ │ └── TOTPConfig.swift +│ ├── KDBX/ +│ │ ├── KDBXDocument.swift +│ │ ├── KDBXMapper.swift +│ │ └── KDBXError.swift +│ ├── TOTP/ +│ │ └── TOTPGenerator.swift +│ ├── Keychain/ +│ │ ├── KeychainStore.swift +│ │ └── KeychainError.swift +│ ├── AutoFill/ +│ │ └── CredentialMatcher.swift +│ └── Session/ +│ └── VaultSession.swift +└── Tests/MyPassCoreTests/ + ├── ProtectedStringTests.swift + ├── TOTPGeneratorTests.swift + ├── CredentialMatcherTests.swift + ├── KDBXDocumentTests.swift + └── Fixtures/ + └── test.kdbx ← copied in Task 4 + +MyPass/ ← existing main app target +├── MyPassApp.swift ← modify: remove SwiftData, add lifecycle +├── ContentView.swift ← replace: route Unlock ↔ GroupBrowser +├── Item.swift ← delete +├── Services/ +│ ├── FileBookmarkService.swift ← new +│ ├── BiometricAuthService.swift ← new +│ └── ClipboardService.swift ← new +├── ViewModels/ +│ ├── UnlockViewModel.swift ← new +│ ├── VaultViewModel.swift ← new +│ └── EntryEditViewModel.swift ← new +└── Views/ + ├── UnlockView.swift ← new + ├── GroupBrowserView.swift ← new + ├── EntryDetailView.swift ← new + ├── EntryEditView.swift ← new + └── SearchView.swift ← new + +AutoFillExtension/ ← new App Extension target +├── AutoFillViewController.swift +├── Info.plist +└── Views/ + ├── ExtensionUnlockView.swift + └── CredentialListView.swift +``` + +--- + +## Phase 1 — MyPassCore Package + +### Task 1: Create MyPassCore local Swift Package + +**Files:** +- Create: `MyPassCore/Package.swift` +- Create directory tree: `MyPassCore/Sources/MyPassCore/` subdirectories +- Create directory tree: `MyPassCore/Tests/MyPassCoreTests/Fixtures/` + +- [ ] **Step 1: Verify KeePassKit SPM availability** + +Open https://github.com/mstarke/KeePassKit. Confirm a `Package.swift` exists at the repository root. If yes, proceed. If no, see the **Fallback** section at the end of this task before writing `Package.swift`. + +- [ ] **Step 2: Create directory structure** + +```bash +mkdir -p MyPassCore/Sources/MyPassCore/Models +mkdir -p MyPassCore/Sources/MyPassCore/KDBX +mkdir -p MyPassCore/Sources/MyPassCore/TOTP +mkdir -p MyPassCore/Sources/MyPassCore/Keychain +mkdir -p MyPassCore/Sources/MyPassCore/AutoFill +mkdir -p MyPassCore/Sources/MyPassCore/Session +mkdir -p MyPassCore/Tests/MyPassCoreTests/Fixtures +``` + +- [ ] **Step 3: Write Package.swift** + +```swift +// MyPassCore/Package.swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "MyPassCore", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "MyPassCore", targets: ["MyPassCore"]), + ], + dependencies: [ + .package(url: "https://github.com/mstarke/KeePassKit", branch: "master"), + ], + targets: [ + .target( + name: "MyPassCore", + dependencies: [ + .product(name: "KeePassKit", package: "KeePassKit"), + ] + ), + .testTarget( + name: "MyPassCoreTests", + dependencies: ["MyPassCore"], + resources: [.copy("Fixtures")] + ), + ] +) +``` + +- [ ] **Step 4: Add the package to the Xcode project** + +In Xcode: **File → Add Package Dependencies... → Add Local...** → select the `MyPassCore/` folder → click **Add Package**. In the dialog, check **MyPassCore** as a dependency of the **MyPass** target. (The AutoFill extension will be added in Task 9.) + +- [ ] **Step 5: Delete Item.swift** + +In Xcode's file navigator, right-click `Item.swift` → **Delete** → **Move to Trash**. + +- [ ] **Step 6: Commit** + +```bash +git add MyPassCore/ MyPass/ +git commit -m "feat: scaffold MyPassCore Swift Package" +``` + +**Fallback — if KeePassKit has no Package.swift:** + +```bash +mkdir -p Vendor +git submodule add https://github.com/mstarke/KeePassKit Vendor/KeePassKit +``` + +In `Package.swift` replace the remote `.package(url:branch:)` with: +```swift +.package(path: "../Vendor/KeePassKit"), +``` +If the submodule itself has no `Package.swift`, create one at `Vendor/KeePassKit/Package.swift`: +```swift +// swift-tools-version: 5.9 +import PackageDescription +let package = Package( + name: "KeePassKit", + products: [.library(name: "KeePassKit", targets: ["KeePassKit"])], + targets: [ + .target( + name: "KeePassKit", + path: "KeePassKit", + publicHeadersPath: ".", + cSettings: [.headerSearchPath(".")] + ) + ] +) +``` + +--- + +### Task 2: ProtectedString + tests + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/Models/ProtectedString.swift` +- Create: `MyPassCore/Tests/MyPassCoreTests/ProtectedStringTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +// MyPassCore/Tests/MyPassCoreTests/ProtectedStringTests.swift +import XCTest +@testable import MyPassCore + +final class ProtectedStringTests: XCTestCase { + func test_reveal_returnsOriginalValue() { + let ps = ProtectedString("hunter2", isProtected: true) + XCTAssertEqual(ps.reveal(), "hunter2") + } + + func test_description_isRedactedWhenProtected() { + let ps = ProtectedString("secret", isProtected: true) + XCTAssertEqual("\(ps)", "***") + } + + func test_description_isPlainWhenNotProtected() { + let ps = ProtectedString("visible", isProtected: false) + XCTAssertEqual("\(ps)", "visible") + } + + func test_equality_matchesByRevealedValue() { + let a = ProtectedString("abc", isProtected: true) + let b = ProtectedString("abc", isProtected: true) + XCTAssertEqual(a, b) + } + + func test_debugDescription_neverRevealSecret() { + let ps = ProtectedString("topsecret", isProtected: true) + XCTAssertFalse(ps.debugDescription.contains("topsecret")) + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +In Xcode: **Product → Test** (or `⌘U`). `MyPassCoreTests` fails because `ProtectedString` does not exist. + +- [ ] **Step 3: Implement ProtectedString** + +```swift +// MyPassCore/Sources/MyPassCore/Models/ProtectedString.swift +import Foundation + +public struct ProtectedString: Equatable { + private let obfuscated: [UInt8] + private let key: [UInt8] + public let isProtected: Bool + + public init(_ value: String, isProtected: Bool = true) { + self.isProtected = isProtected + let bytes = Array(value.utf8) + let k = (0 ..< bytes.count).map { _ in UInt8.random(in: 0 ... 255) } + self.key = k + self.obfuscated = zip(bytes, k).map { $0 ^ $1 } + } + + public func reveal() -> String { + let bytes = zip(obfuscated, key).map { $0 ^ $1 } + return String(bytes: bytes, encoding: .utf8) ?? "" + } + + public static func == (lhs: ProtectedString, rhs: ProtectedString) -> Bool { + lhs.reveal() == rhs.reveal() && lhs.isProtected == rhs.isProtected + } +} + +extension ProtectedString: CustomStringConvertible { + public var description: String { isProtected ? "***" : reveal() } +} + +extension ProtectedString: CustomDebugStringConvertible { + public var debugDescription: String { "ProtectedString(isProtected: \(isProtected))" } +} +``` + +- [ ] **Step 4: Run tests — all pass** + +`⌘U` → `ProtectedStringTests` passes (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add ProtectedString with XOR obfuscation" +``` + +--- + +### Task 3: Core data models + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/Models/DatabaseMetadata.swift` +- Create: `MyPassCore/Sources/MyPassCore/Models/KDBXDatabase.swift` +- Create: `MyPassCore/Sources/MyPassCore/Models/TOTPConfig.swift` +- Create: `MyPassCore/Sources/MyPassCore/Models/CustomField.swift` +- Create: `MyPassCore/Sources/MyPassCore/Models/Attachment.swift` +- Create: `MyPassCore/Sources/MyPassCore/Models/Entry.swift` +- Create: `MyPassCore/Sources/MyPassCore/Models/Group.swift` + +- [ ] **Step 1: Write all model files** + +```swift +// MyPassCore/Sources/MyPassCore/Models/DatabaseMetadata.swift +import Foundation + +public struct DatabaseMetadata: Equatable { + public var name: String + public var description: String + public init(name: String, description: String = "") { + self.name = name + self.description = description + } +} +``` + +```swift +// MyPassCore/Sources/MyPassCore/Models/TOTPConfig.swift +import Foundation + +public enum TOTPAlgorithm: String, Equatable, CaseIterable { + case sha1 = "SHA1" + case sha256 = "SHA256" + case sha512 = "SHA512" +} + +public struct TOTPConfig: Equatable { + public var secret: String + public var period: Int + public var digits: Int + public var algorithm: TOTPAlgorithm + + public init(secret: String, period: Int = 30, digits: Int = 6, algorithm: TOTPAlgorithm = .sha1) { + self.secret = secret + self.period = period + self.digits = digits + self.algorithm = algorithm + } + + /// Parses an otpauth://totp/ URI (standard KeePass TOTP storage format). + public static func parse(from uri: String) -> TOTPConfig? { + guard let url = URL(string: uri), + url.scheme == "otpauth", + url.host == "totp", + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + let params = Dictionary( + uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + } + ) + guard let secret = params["secret"] else { return nil } + let period = Int(params["period"] ?? "30") ?? 30 + let digits = Int(params["digits"] ?? "6") ?? 6 + let algo: TOTPAlgorithm + switch params["algorithm"]?.uppercased() { + case "SHA256": algo = .sha256 + case "SHA512": algo = .sha512 + default: algo = .sha1 + } + return TOTPConfig(secret: secret, period: period, digits: digits, algorithm: algo) + } +} +``` + +```swift +// MyPassCore/Sources/MyPassCore/Models/CustomField.swift +import Foundation + +public struct CustomField: Identifiable, Equatable { + public var id: UUID + public var key: String + public var value: ProtectedString + + public init(id: UUID = UUID(), key: String, value: ProtectedString) { + self.id = id + self.key = key + self.value = value + } +} +``` + +```swift +// MyPassCore/Sources/MyPassCore/Models/Attachment.swift +import Foundation + +public struct Attachment: Identifiable, Equatable { + public var id: UUID + public var name: String + public var data: Data + + public init(id: UUID = UUID(), name: String, data: Data) { + self.id = id + self.name = name + self.data = data + } +} +``` + +```swift +// MyPassCore/Sources/MyPassCore/Models/Entry.swift +import Foundation + +public struct Entry: Identifiable, Equatable, Hashable { + public var id: UUID + public var title: String + public var username: String + public var password: ProtectedString + public var url: String + public var notes: String + public var customFields: [CustomField] + public var attachments: [Attachment] + public var totp: TOTPConfig? + public var tags: [String] + public var iconIndex: Int + public var expiryDate: Date? + public var creationDate: Date + public var modificationDate: Date + public var history: [Entry] + + public init( + id: UUID = UUID(), + title: String = "", + username: String = "", + password: ProtectedString = ProtectedString("", isProtected: true), + url: String = "", + notes: String = "", + customFields: [CustomField] = [], + attachments: [Attachment] = [], + totp: TOTPConfig? = nil, + tags: [String] = [], + iconIndex: Int = 0, + expiryDate: Date? = nil, + creationDate: Date = Date(), + modificationDate: Date = Date(), + history: [Entry] = [] + ) { + self.id = id + self.title = title + self.username = username + self.password = password + self.url = url + self.notes = notes + self.customFields = customFields + self.attachments = attachments + self.totp = totp + self.tags = tags + self.iconIndex = iconIndex + self.expiryDate = expiryDate + self.creationDate = creationDate + self.modificationDate = modificationDate + self.history = history + } + + // Hashable by ID so SwiftUI List(selection:) works without requiring all fields to be Hashable. + public func hash(into hasher: inout Hasher) { hasher.combine(id) } + public static func == (lhs: Entry, rhs: Entry) -> Bool { lhs.id == rhs.id } +} +``` + +```swift +// MyPassCore/Sources/MyPassCore/Models/Group.swift +import Foundation + +public struct Group: Identifiable, Equatable, Hashable { + public var id: UUID + public var name: String + public var iconIndex: Int + public var subgroups: [Group] + public var entries: [Entry] + + public init( + id: UUID = UUID(), + name: String, + iconIndex: Int = 0, + subgroups: [Group] = [], + entries: [Entry] = [] + ) { + self.id = id + self.name = name + self.iconIndex = iconIndex + self.subgroups = subgroups + self.entries = entries + } + + // Hashable by ID so SwiftUI List(selection:) works without hashing the full tree. + public func hash(into hasher: inout Hasher) { hasher.combine(id) } + public static func == (lhs: Group, rhs: Group) -> Bool { lhs.id == rhs.id } +} +``` + +```swift +// MyPassCore/Sources/MyPassCore/Models/KDBXDatabase.swift +import Foundation + +public struct KDBXDatabase: Equatable { + public var metadata: DatabaseMetadata + public var root: Group + + public init(metadata: DatabaseMetadata, root: Group) { + self.metadata = metadata + self.root = root + } +} +``` + +- [ ] **Step 2: Build to confirm it compiles** + +`⌘B` in Xcode. No errors expected (pure value types, no dependencies). + +- [ ] **Step 3: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add core KDBX data models" +``` + +--- + +### Task 4: KDBXError + KDBXDocument + KDBXMapper + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift` +- Create: `MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift` +- Create: `MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift` +- Create: `MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift` +- Copy: a KDBX test fixture to `MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx` + +- [ ] **Step 1: Obtain a test KDBX fixture** + +Download a sample KDBX 4 database from https://keepass.info/help/kb/testfiles_stable.html (or copy `Test Files/Format4.kdbx` from the KeePassKit repository). Save it as: +``` +MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx +``` +Note the password for this file (typically `master` for KeePassKit test files). + +- [ ] **Step 2: Write the failing test** + +```swift +// MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift +import XCTest +@testable import MyPassCore + +final class KDBXDocumentTests: XCTestCase { + var fixtureURL: URL! + + override func setUp() { + super.setUp() + fixtureURL = Bundle.module.url(forResource: "test", withExtension: "kdbx", subdirectory: "Fixtures")! + } + + func test_read_parsesRootGroup() throws { + let doc = KDBXDocument(url: fixtureURL) + let db = try doc.read(password: "master") + XCTAssertFalse(db.root.name.isEmpty) + } + + func test_read_wrongPassword_throwsInvalidPassword() throws { + let doc = KDBXDocument(url: fixtureURL) + XCTAssertThrowsError(try doc.read(password: "wrong")) { error in + XCTAssertEqual(error as? KDBXError, .invalidPassword) + } + } + + func test_roundTrip_preservesEntryTitle() throws { + let doc = KDBXDocument(url: fixtureURL) + var db = try doc.read(password: "master") + let newEntry = Entry(title: "RoundTripTest", username: "user", password: ProtectedString("pass")) + db.root.entries.append(newEntry) + let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("roundtrip.kdbx") + let tmpDoc = KDBXDocument(url: tmpURL) + try tmpDoc.write(db, password: "master") + let reloaded = try KDBXDocument(url: tmpURL).read(password: "master") + XCTAssertTrue(reloaded.root.entries.contains { $0.title == "RoundTripTest" }) + } +} +``` + +- [ ] **Step 3: Run to confirm failure** + +`⌘U` → fails because `KDBXDocument`, `KDBXError` don't exist yet. + +- [ ] **Step 4: Write KDBXError** + +```swift +// MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift +import Foundation + +public enum KDBXError: Error, Equatable { + case invalidPassword + case fileNotFound + case parseError(String) + case writeError(String) +} +``` + +- [ ] **Step 5: Write KDBXMapper** + +> **Note:** The following uses KeePassKit's public API. If property names differ from what you see in the library headers (e.g. `childGroups` vs `groups`), adjust to match. The KeePassKit header files are the source of truth. + +```swift +// MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift +import Foundation +import KeePassKit + +enum KDBXMapper { + // MARK: KeePassKit → MyPassCore + + static func database(from tree: KPKTree) -> KDBXDatabase { + let meta = DatabaseMetadata( + name: tree.metaData?.databaseName ?? "", + description: tree.metaData?.databaseDescription ?? "" + ) + let root = group(from: tree.root ?? KPKGroup()) + return KDBXDatabase(metadata: meta, root: root) + } + + static func group(from g: KPKGroup) -> Group { + Group( + id: uuid(from: g.uuid), + name: g.name ?? "", + iconIndex: Int(g.iconId), + subgroups: (g.childGroups as? [KPKGroup] ?? []).map { group(from: $0) }, + entries: (g.childEntries as? [KPKEntry] ?? []).map { entry(from: $0) } + ) + } + + static func entry(from e: KPKEntry) -> Entry { + let customFields: [CustomField] = (e.customAttributes as? [KPKAttribute] ?? []) + .filter { !reservedKeys.contains($0.key ?? "") } + .map { + CustomField( + id: UUID(), + key: $0.key ?? "", + value: ProtectedString($0.value ?? "", isProtected: $0.isProtected) + ) + } + + let totpURI = (e.customAttributes as? [KPKAttribute] ?? []) + .first { $0.key == "otp" }?.value + let totpConfig = totpURI.flatMap { TOTPConfig.parse(from: $0) } + + let attachments: [Attachment] = (e.binaries as? [KPKBinary] ?? []).map { + Attachment(id: UUID(), name: $0.name ?? "", data: $0.data ?? Data()) + } + + return Entry( + id: uuid(from: e.uuid), + title: e.title ?? "", + username: e.username ?? "", + password: ProtectedString(e.password ?? "", isProtected: true), + url: e.url ?? "", + notes: e.notes ?? "", + customFields: customFields, + attachments: attachments, + totp: totpConfig, + tags: [], + iconIndex: Int(e.iconId), + expiryDate: e.timeInfo?.expiryDate, + creationDate: e.timeInfo?.creationDate ?? Date(), + modificationDate: e.timeInfo?.modificationDate ?? Date(), + history: [] + ) + } + + // MARK: MyPassCore → KeePassKit + + static func tree(from db: KDBXDatabase) -> KPKTree { + let tree = KPKTree() + tree.root = kpkGroup(from: db.root) + tree.metaData?.databaseName = db.metadata.name + tree.metaData?.databaseDescription = db.metadata.description + return tree + } + + static func kpkGroup(from g: Group) -> KPKGroup { + let kpk = KPKGroup() + kpk.name = g.name + kpk.iconId = UInt32(g.iconIndex) + for sub in g.subgroups { + kpk.addGroup(kpkGroup(from: sub), undoManager: nil) + } + for e in g.entries { + kpk.addEntry(kpkEntry(from: e), undoManager: nil) + } + return kpk + } + + static func kpkEntry(from e: Entry) -> KPKEntry { + let kpk = KPKEntry() + kpk.title = e.title + kpk.username = e.username + kpk.password = e.password.reveal() + kpk.url = e.url + kpk.notes = e.notes + kpk.iconId = UInt32(e.iconIndex) + for field in e.customFields { + let attr = KPKAttribute(key: field.key, value: field.value.reveal(), isProtected: field.value.isProtected) + kpk.addCustomAttribute(attr) + } + for att in e.attachments { + let bin = KPKBinary(named: att.name, with: att.data) + kpk.addBinary(bin) + } + return kpk + } + + // MARK: Helpers + + private static func uuid(from nsUUID: NSUUID?) -> UUID { + guard let u = nsUUID else { return UUID() } + return UUID(uuidString: u.uuidString) ?? UUID() + } + + private static let reservedKeys: Set = ["Title", "UserName", "Password", "URL", "Notes"] +} +``` + +- [ ] **Step 6: Write KDBXDocument** + +```swift +// MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift +import Foundation +import KeePassKit + +public struct KDBXDocument { + public let url: URL + + public init(url: URL) { + self.url = url + } + + public func read(password: String) throws -> KDBXDatabase { + guard FileManager.default.fileExists(atPath: url.path) else { + throw KDBXError.fileNotFound + } + let key = KPKCompositeKey() + try key.addPasswordData(Data(password.utf8)) + do { + let tree = try KPKTree(contentsOf: url, key: key) + return KDBXMapper.database(from: tree) + } catch let error as NSError { + if error.domain == KPKErrorDomain || error.code == KPKErrorCode.incorrectKey.rawValue { + throw KDBXError.invalidPassword + } + throw KDBXError.parseError(error.localizedDescription) + } + } + + public func write(_ database: KDBXDatabase, password: String) throws { + let tree = KDBXMapper.tree(from: database) + let key = KPKCompositeKey() + try key.addPasswordData(Data(password.utf8)) + do { + try tree.write(to: url, key: key) + } catch { + throw KDBXError.writeError(error.localizedDescription) + } + } +} +``` + +- [ ] **Step 7: Run tests — should pass** + +`⌘U`. Three `KDBXDocumentTests` pass. If `test_read_wrongPassword_throwsInvalidPassword` fails because the error domain constant is different, check KeePassKit's error constants and adjust the catch clause in `KDBXDocument.read`. + +- [ ] **Step 8: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add KDBXDocument with KeePassKit-backed KDBX parsing" +``` + +--- + +### Task 5: TOTPGenerator + tests + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift` +- Create: `MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift` + +- [ ] **Step 1: Write failing tests (RFC 6238 test vectors)** + +```swift +// MyPassCore/Tests/MyPassCoreTests/TOTPGeneratorTests.swift +import XCTest +@testable import MyPassCore + +final class TOTPGeneratorTests: XCTestCase { + // RFC 6238 Section 8 test vectors for SHA-1 + // secret = "12345678901234567890" (ASCII), base32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + let sha1Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + func test_sha1_at59s() { + let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1) + let date = Date(timeIntervalSince1970: 59) + XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "94287082") + } + + func test_sha1_at1111111109s() { + let config = TOTPConfig(secret: sha1Secret, period: 30, digits: 8, algorithm: .sha1) + let date = Date(timeIntervalSince1970: 1111111109) + XCTAssertEqual(TOTPGenerator.generate(config: config, at: date), "07081804") + } + + func test_secondsRemaining_isWithinPeriod() { + let config = TOTPConfig(secret: sha1Secret, period: 30) + let remaining = TOTPGenerator.secondsRemaining(config: config, at: Date()) + XCTAssertGreaterThan(remaining, 0) + XCTAssertLessThanOrEqual(remaining, 30) + } + + func test_generate_defaultSixDigits() { + let config = TOTPConfig(secret: sha1Secret) + let code = TOTPGenerator.generate(config: config, at: Date()) + XCTAssertEqual(code.count, 6) + XCTAssertNotNil(Int(code)) + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +`⌘U` → fails — `TOTPGenerator` not defined. + +- [ ] **Step 3: Implement TOTPGenerator** + +```swift +// MyPassCore/Sources/MyPassCore/TOTP/TOTPGenerator.swift +import Foundation +import CryptoKit + +public enum TOTPGenerator { + public static func generate(config: TOTPConfig, at date: Date = Date()) -> String { + let counter = UInt64(date.timeIntervalSince1970) / UInt64(config.period) + let keyBytes = base32Decode(config.secret) + let counterBytes = withUnsafeBytes(of: counter.bigEndian, Array.init) + let symKey = SymmetricKey(data: keyBytes) + let hmacBytes: [UInt8] + switch config.algorithm { + case .sha1: + hmacBytes = Array(HMAC.authenticationCode(for: counterBytes, using: symKey)) + case .sha256: + hmacBytes = Array(HMAC.authenticationCode(for: counterBytes, using: symKey)) + case .sha512: + hmacBytes = Array(HMAC.authenticationCode(for: counterBytes, using: symKey)) + } + let offset = Int(hmacBytes[hmacBytes.count - 1] & 0x0f) + let truncated = ((Int(hmacBytes[offset]) & 0x7f) << 24) + | (Int(hmacBytes[offset + 1]) << 16) + | (Int(hmacBytes[offset + 2]) << 8) + | Int(hmacBytes[offset + 3]) + let otp = truncated % Int(pow(10.0, Double(config.digits))) + return String(format: "%0\(config.digits)d", otp) + } + + public static func secondsRemaining(config: TOTPConfig, at date: Date = Date()) -> Int { + let elapsed = Int(date.timeIntervalSince1970) % config.period + return config.period - elapsed + } + + private static func base32Decode(_ input: String) -> [UInt8] { + let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + let s = input.uppercased().filter { alphabet.contains($0) } + var result: [UInt8] = [] + var buffer = 0 + var bitsLeft = 0 + for char in s { + guard let idx = alphabet.firstIndex(of: char) else { continue } + buffer = (buffer << 5) | alphabet.distance(from: alphabet.startIndex, to: idx) + bitsLeft += 5 + if bitsLeft >= 8 { + bitsLeft -= 8 + result.append(UInt8((buffer >> bitsLeft) & 0xff)) + } + } + return result + } +} +``` + +- [ ] **Step 4: Run tests — all pass** + +`⌘U` → 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add TOTPGenerator (RFC 6238)" +``` + +--- + +### Task 6: KeychainStore + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift` +- Create: `MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift` + +> Keychain operations require a real device or simulator entitlements and cannot be unit-tested in a plain SPM test target. Correctness is verified via integration in Task 12 (UnlockView). + +- [ ] **Step 1: Write KeychainError** + +```swift +// MyPassCore/Sources/MyPassCore/Keychain/KeychainError.swift +import Foundation + +public enum KeychainError: Error, Equatable { + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case notFound +} +``` + +- [ ] **Step 2: Write KeychainStore** + +```swift +// MyPassCore/Sources/MyPassCore/Keychain/KeychainStore.swift +import Foundation +import Security + +public struct KeychainStore { + private let accessGroup: String + private let service: String + + public init(accessGroup: String, service: String = "com.christophevila.mypass") { + self.accessGroup = accessGroup + self.service = service + } + + public func save(password: String, for account: String) throws { + let data = Data(password.utf8) + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + kSecValueData: data, + ] + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } + } + + public func load(for account: String) throws -> String { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess else { + throw status == errSecItemNotFound ? KeychainError.notFound : KeychainError.loadFailed(status) + } + guard let data = result as? Data, let password = String(data: data, encoding: .utf8) else { + throw KeychainError.loadFailed(errSecInvalidData) + } + return password + } + + public func delete(for account: String) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + ] + SecItemDelete(query as CFDictionary) + } + + public func exists(for account: String) -> Bool { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecMatchLimit: kSecMatchLimitOne, + ] + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } +} +``` + +- [ ] **Step 3: Build to confirm compile** + +`⌘B` — no errors. + +- [ ] **Step 4: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add KeychainStore" +``` + +--- + +### Task 7: CredentialMatcher + tests + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift` +- Create: `MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift` + +- [ ] **Step 1: Write failing tests** + +```swift +// MyPassCore/Tests/MyPassCoreTests/CredentialMatcherTests.swift +import XCTest +@testable import MyPassCore + +final class CredentialMatcherTests: XCTestCase { + func makeEntry(url: String) -> Entry { + Entry(title: "Test", url: url) + } + + func test_exactURLMatch() { + let e = makeEntry(url: "https://github.com/login") + XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_wwwStripped() { + let e = makeEntry(url: "https://www.github.com") + XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_subdomainMatch() { + let e = makeEntry(url: "https://api.github.com") + XCTAssertTrue(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_differentDomain_noMatch() { + let e = makeEntry(url: "https://gitlab.com") + XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_emptyURL_noMatch() { + let e = makeEntry(url: "") + XCTAssertFalse(CredentialMatcher.matches(entry: e, serviceIdentifier: "https://github.com")) + } + + func test_filter_suggestedAndRest() { + let entries = [ + makeEntry(url: "https://github.com"), + makeEntry(url: "https://gitlab.com"), + makeEntry(url: ""), + ] + let result = CredentialMatcher.filter(entries: entries, for: ["https://github.com"]) + XCTAssertEqual(result.suggested.count, 1) + XCTAssertEqual(result.suggested[0].url, "https://github.com") + XCTAssertEqual(result.all.count, 2) + } +} +``` + +- [ ] **Step 2: Run to confirm failure** + +`⌘U` → fails — `CredentialMatcher` not defined. + +- [ ] **Step 3: Implement CredentialMatcher** + +```swift +// MyPassCore/Sources/MyPassCore/AutoFill/CredentialMatcher.swift +import Foundation + +public enum CredentialMatcher { + public static func matches(entry: Entry, serviceIdentifier: String) -> Bool { + guard !entry.url.isEmpty else { return false } + guard let entryHost = host(from: entry.url), + let serviceHost = host(from: serviceIdentifier) + else { + return entry.url.lowercased().contains(serviceIdentifier.lowercased()) + } + return hostsMatch(entryHost, serviceHost) + } + + public static func filter( + entries: [Entry], + for serviceIdentifiers: [String] + ) -> (suggested: [Entry], all: [Entry]) { + guard !serviceIdentifiers.isEmpty else { return ([], entries) } + let suggested = entries.filter { entry in + serviceIdentifiers.contains { matches(entry: entry, serviceIdentifier: $0) } + } + let suggestedIDs = Set(suggested.map(\.id)) + let rest = entries.filter { !suggestedIDs.contains($0.id) } + return (suggested: suggested, all: rest) + } + + private static func host(from urlString: String) -> String? { + URL(string: urlString)?.host + } + + private static func hostsMatch(_ a: String, _ b: String) -> Bool { + let na = stripped(a) + let nb = stripped(b) + return na == nb + || na.hasSuffix("." + nb) + || nb.hasSuffix("." + na) + } + + private static func stripped(_ host: String) -> String { + host.hasPrefix("www.") ? String(host.dropFirst(4)).lowercased() : host.lowercased() + } +} +``` + +- [ ] **Step 4: Run — 6 tests pass** + +`⌘U`. + +- [ ] **Step 5: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add CredentialMatcher for AutoFill URL matching" +``` + +--- + +### Task 8: VaultSession + +**Files:** +- Create: `MyPassCore/Sources/MyPassCore/Session/VaultSession.swift` + +- [ ] **Step 1: Write VaultSession** + +```swift +// MyPassCore/Sources/MyPassCore/Session/VaultSession.swift +import Foundation +import Combine + +@MainActor +public final class VaultSession: ObservableObject { + @Published public private(set) var database: KDBXDatabase? + @Published public private(set) var isLocked: Bool = true + + private var document: KDBXDocument? + private var masterPassword: String = "" + + public init() {} + + public func unlock(url: URL, password: String) throws { + let doc = KDBXDocument(url: url) + let db = try doc.read(password: password) + document = doc + database = db + masterPassword = password + isLocked = false + } + + public func lock() { + masterPassword = "" + database = nil + document = nil + isLocked = true + } + + public func save() throws { + guard let db = database, let doc = document, !masterPassword.isEmpty else { return } + try doc.write(db, password: masterPassword) + } + + // MARK: Entry CRUD + + public func addEntry(_ entry: Entry, toGroupId groupId: UUID) throws { + guard var db = database else { return } + mutateGroup(id: groupId, in: &db.root) { $0.entries.append(entry) } + database = db + try save() + } + + public func updateEntry(_ entry: Entry) throws { + guard var db = database else { return } + updateEntryInTree(entry, in: &db.root) + database = db + try save() + } + + public func deleteEntry(id entryId: UUID, fromGroupId groupId: UUID) throws { + guard var db = database else { return } + mutateGroup(id: groupId, in: &db.root) { $0.entries.removeAll { $0.id == entryId } } + database = db + try save() + } + + // MARK: Search + + public func allEntries() -> [Entry] { + guard let db = database else { return [] } + return flatEntries(in: db.root) + } + + // MARK: Helpers + + private func mutateGroup(id: UUID, in group: inout Group, _ mutation: (inout Group) -> Void) { + if group.id == id { + mutation(&group) + return + } + for i in group.subgroups.indices { + mutateGroup(id: id, in: &group.subgroups[i], mutation) + } + } + + private func updateEntryInTree(_ entry: Entry, in group: inout Group) { + if let idx = group.entries.firstIndex(where: { $0.id == entry.id }) { + var updated = entry + updated.modificationDate = Date() + group.entries[idx] = updated + return + } + for i in group.subgroups.indices { + updateEntryInTree(entry, in: &group.subgroups[i]) + } + } + + private func flatEntries(in group: Group) -> [Entry] { + group.entries + group.subgroups.flatMap { flatEntries(in: $0) } + } +} +``` + +- [ ] **Step 2: Build — no errors** + +`⌘B`. + +- [ ] **Step 3: Commit** + +```bash +git add MyPassCore/ +git commit -m "feat: add VaultSession (vault lifecycle and entry CRUD)" +``` + +--- + +## Phase 2 — Xcode Project Setup + +### Task 9: App Group, Keychain Access Group, AutoFill Extension target + +> All steps in this task are done in Xcode's GUI. No code files are created — only project settings. + +- [ ] **Step 1: Set the main app Bundle ID** + +Select the **MyPass** project → **MyPass** target → **Signing & Capabilities**. Set Bundle Identifier to `com.christophevila.mypass`. + +- [ ] **Step 2: Add App Group to MyPass target** + +Still in **Signing & Capabilities** for **MyPass**: click **+ Capability** → **App Groups**. Add `group.com.christophevila.mypass`. + +- [ ] **Step 3: Add Keychain Sharing to MyPass target** + +Click **+ Capability** → **Keychain Sharing**. Add keychain group `com.christophevila.mypass`. + +- [ ] **Step 4: Create the AutoFill Extension target** + +**File → New → Target → AutoFill Credential Provider Extension**. Set: +- Product Name: `AutoFillExtension` +- Bundle ID: `com.christophevila.mypass.autofill` +- Language: Swift +- Embed in: **MyPass** + +Click **Finish**. + +- [ ] **Step 5: Add App Group + Keychain Sharing to the extension target** + +Select the **AutoFillExtension** target → **Signing & Capabilities**. Add the same App Group (`group.com.christophevila.mypass`) and Keychain Sharing (`com.christophevila.mypass`) as in Steps 2–3. + +- [ ] **Step 6: Add MyPassCore to the extension target** + +Select the **AutoFillExtension** target → **General → Frameworks and Libraries**. Click **+** → select `MyPassCore`. + +- [ ] **Step 7: Remove SwiftData from MyPassApp.swift (prep)** + +Open `MyPass/MyPassApp.swift`. Remove the `import SwiftData` line and the `.modelContainer(sharedModelContainer)` modifier. The file should now be minimal: + +```swift +import SwiftUI + +@main +struct MyPassApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +- [ ] **Step 8: Build to confirm the project compiles** + +`⌘B`. Errors about `Item` no longer existing are expected and will be resolved in Task 17. + +- [ ] **Step 9: Commit** + +```bash +git add MyPass/MyPassApp.swift +git commit -m "chore: configure App Group, Keychain Sharing, and AutoFill Extension target" +``` + +--- + +## Phase 3 — App Services + +### Task 10: FileBookmarkService + +**Files:** +- Create: `MyPass/Services/FileBookmarkService.swift` + +- [ ] **Step 1: Write FileBookmarkService** + +```swift +// MyPass/Services/FileBookmarkService.swift +import Foundation + +/// Persists a security-scoped bookmark for the user's KDBX file in the shared App Group. +public final class FileBookmarkService { + private static let key = "kdbxBookmark" + private let defaults: UserDefaults + + public init(appGroup: String = "group.com.christophevila.mypass") { + defaults = UserDefaults(suiteName: appGroup) ?? .standard + } + + public func save(url: URL) throws { + let data = try url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + defaults.set(data, forKey: Self.key) + } + + /// Returns the resolved URL, starting security access. Caller must call `stopAccess(url:)` when done. + public func resolveURL() throws -> URL { + guard let data = defaults.data(forKey: Self.key) else { throw BookmarkError.notFound } + var isStale = false + let url = try URL( + resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + if isStale { + let fresh = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) + defaults.set(fresh, forKey: Self.key) + } + guard url.startAccessingSecurityScopedResource() else { throw BookmarkError.accessDenied } + return url + } + + public func stopAccess(url: URL) { + url.stopAccessingSecurityScopedResource() + } + + public func clear() { + defaults.removeObject(forKey: Self.key) + } + + public var hasBookmark: Bool { + defaults.data(forKey: Self.key) != nil + } +} + +public enum BookmarkError: Error { + case notFound + case accessDenied +} +``` + +- [ ] **Step 2: Build — no errors** `⌘B`. + +- [ ] **Step 3: Commit** + +```bash +git add MyPass/Services/FileBookmarkService.swift +git commit -m "feat: add FileBookmarkService" +``` + +--- + +### Task 11: BiometricAuthService + ClipboardService + +**Files:** +- Create: `MyPass/Services/BiometricAuthService.swift` +- Create: `MyPass/Services/ClipboardService.swift` + +- [ ] **Step 1: Write BiometricAuthService** + +```swift +// MyPass/Services/BiometricAuthService.swift +import LocalAuthentication +import Foundation + +public final class BiometricAuthService { + public var isAvailable: Bool { + let ctx = LAContext() + var error: NSError? + return ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + } + + /// Returns true on success. On failure, throws an error with a localized message. + public func authenticate(reason: String) async throws { + let ctx = LAContext() + try await ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) + } +} +``` + +- [ ] **Step 2: Write ClipboardService** + +```swift +// MyPass/Services/ClipboardService.swift +import Foundation +#if os(iOS) +import UIKit +#else +import AppKit +#endif + +public enum ClipboardService { + public static func copy(_ text: String, expiresAfter seconds: TimeInterval = 30) { + #if os(iOS) + UIPasteboard.general.setItems( + [[UIPasteboard.typeAutomatic: text]], + options: [.expirationDate: Date().addingTimeInterval(seconds)] + ) + #else + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + let captured = text + DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { + if pb.string(forType: .string) == captured { pb.clearContents() } + } + #endif + } +} +``` + +- [ ] **Step 3: Build** `⌘B`. + +- [ ] **Step 4: Commit** + +```bash +git add MyPass/Services/ +git commit -m "feat: add BiometricAuthService and ClipboardService" +``` + +--- + +## Phase 4 — App UI + +### Task 12: UnlockViewModel + UnlockView + +**Files:** +- Create: `MyPass/ViewModels/UnlockViewModel.swift` +- Create: `MyPass/Views/UnlockView.swift` + +- [ ] **Step 1: Write UnlockViewModel** + +```swift +// MyPass/ViewModels/UnlockViewModel.swift +import Foundation +import MyPassCore + +@MainActor +final class UnlockViewModel: ObservableObject { + @Published var password: String = "" + @Published var errorMessage: String? + @Published var isUnlocking: Bool = false + @Published var showFilePicker: Bool = false + + private let session: VaultSession + private let bookmarkService: FileBookmarkService + private let keychainStore: KeychainStore + private let biometricService: BiometricAuthService + + private let keychainAccount = "masterPassword" + + init( + session: VaultSession, + bookmarkService: FileBookmarkService = .init(), + keychainStore: KeychainStore = .init(accessGroup: "com.christophevila.mypass"), + biometricService: BiometricAuthService = .init() + ) { + self.session = session + self.bookmarkService = bookmarkService + self.keychainStore = keychainStore + self.biometricService = biometricService + } + + var canUseBiometrics: Bool { + biometricService.isAvailable && keychainStore.exists(for: keychainAccount) + } + + var hasVault: Bool { bookmarkService.hasBookmark } + + func unlockWithBiometrics() { + Task { + isUnlocking = true + errorMessage = nil + do { + try await biometricService.authenticate(reason: "Unlock MyPass") + let storedPassword = try keychainStore.load(for: keychainAccount) + let url = try bookmarkService.resolveURL() + defer { bookmarkService.stopAccess(url: url) } + try session.unlock(url: url, password: storedPassword) + } catch { + errorMessage = error.localizedDescription + } + isUnlocking = false + } + } + + func unlockWithPassword() { + Task { + isUnlocking = true + errorMessage = nil + do { + let url = try bookmarkService.resolveURL() + defer { bookmarkService.stopAccess(url: url) } + try session.unlock(url: url, password: password) + try keychainStore.save(password: password, for: keychainAccount) + password = "" + } catch KDBXError.invalidPassword { + errorMessage = "Incorrect password." + } catch { + errorMessage = error.localizedDescription + } + isUnlocking = false + } + } + + func openFile(url: URL) { + do { + try bookmarkService.save(url: url) + unlockWithPassword() + } catch { + errorMessage = error.localizedDescription + } + } +} +``` + +- [ ] **Step 2: Write UnlockView** + +```swift +// MyPass/Views/UnlockView.swift +import SwiftUI +import MyPassCore + +struct UnlockView: View { + @ObservedObject var vm: UnlockViewModel + + var body: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "lock.shield.fill") + .font(.system(size: 64)) + .foregroundStyle(.tint) + Text("MyPass") + .font(.largeTitle.bold()) + + if vm.hasVault { + vaultUnlockSection + } else { + openFileSection + } + + if let msg = vm.errorMessage { + Text(msg) + .foregroundStyle(.red) + .font(.caption) + .multilineTextAlignment(.center) + } + Spacer() + } + .padding(32) + .fileImporter( + isPresented: $vm.showFilePicker, + allowedContentTypes: [.init(filenameExtension: "kdbx")!], + onCompletion: { result in + if let url = try? result.get() { vm.openFile(url: url) } + } + ) + } + + @ViewBuilder + private var vaultUnlockSection: some View { + VStack(spacing: 16) { + if vm.canUseBiometrics { + Button(action: vm.unlockWithBiometrics) { + Label("Use Face ID / Touch ID", systemImage: "faceid") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(vm.isUnlocking) + + Text("or enter password") + .font(.caption) + .foregroundStyle(.secondary) + } + + SecureField("Master Password", text: $vm.password) + .textFieldStyle(.roundedBorder) + .onSubmit(vm.unlockWithPassword) + + Button("Unlock", action: vm.unlockWithPassword) + .buttonStyle(.bordered) + .disabled(vm.password.isEmpty || vm.isUnlocking) + + Button("Choose different file…") { vm.showFilePicker = true } + .font(.caption) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var openFileSection: some View { + VStack(spacing: 16) { + Text("No vault selected. Open a .kdbx file to get started.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + Button(action: { vm.showFilePicker = true }) { + Label("Open KDBX File…", systemImage: "doc.badge.plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + } +} +``` + +- [ ] **Step 3: Build** `⌘B`. + +- [ ] **Step 4: Commit** + +```bash +git add MyPass/ViewModels/UnlockViewModel.swift MyPass/Views/UnlockView.swift +git commit -m "feat: add UnlockView with biometric and password unlock" +``` + +--- + +### Task 13: VaultViewModel + GroupBrowserView + +**Files:** +- Create: `MyPass/ViewModels/VaultViewModel.swift` +- Create: `MyPass/Views/GroupBrowserView.swift` + +- [ ] **Step 1: Write VaultViewModel** + +```swift +// MyPass/ViewModels/VaultViewModel.swift +import Foundation +import MyPassCore + +@MainActor +final class VaultViewModel: ObservableObject { + @Published var searchQuery: String = "" + @Published var errorMessage: String? + + let session: VaultSession + + init(session: VaultSession) { + self.session = session + } + + var filteredEntries: [Entry] { + guard !searchQuery.isEmpty else { return [] } + let q = searchQuery.lowercased() + return session.allEntries().filter { + $0.title.lowercased().contains(q) + || $0.username.lowercased().contains(q) + || $0.url.lowercased().contains(q) + } + } + + var isSearching: Bool { !searchQuery.isEmpty } + + func deleteEntry(_ entry: Entry, fromGroup group: Group) { + Task { + do { try session.deleteEntry(id: entry.id, fromGroupId: group.id) } + catch { errorMessage = error.localizedDescription } + } + } +} +``` + +- [ ] **Step 2: Write GroupBrowserView** + +```swift +// MyPass/Views/GroupBrowserView.swift +import SwiftUI +import MyPassCore + +struct GroupBrowserView: View { + @ObservedObject var vm: VaultViewModel + let group: Group + + @State private var selectedEntry: Entry? + @State private var showAddEntry = false + + var body: some View { + List { + if vm.isSearching { + searchResultsSection + } else { + groupTreeSection + } + } + .navigationTitle(group.name) + .searchable(text: $vm.searchQuery, prompt: "Search all entries…") + .toolbar { + #if os(iOS) + ToolbarItem(placement: .navigationBarTrailing) { EditButton() } + #endif + ToolbarItem(placement: .primaryAction) { + Button { showAddEntry = true } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showAddEntry) { + let editVM = EntryEditViewModel(session: vm.session, groupId: group.id) + EntryEditView(vm: editVM) + } + .alert("Error", isPresented: Binding( + get: { vm.errorMessage != nil }, + set: { if !$0 { vm.errorMessage = nil } } + )) { + Button("OK", role: .cancel) { vm.errorMessage = nil } + } message: { + Text(vm.errorMessage ?? "") + } + } + + @ViewBuilder + private var searchResultsSection: some View { + ForEach(vm.filteredEntries) { entry in + NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) { + EntryRow(entry: entry) + } + } + } + + @ViewBuilder + private var groupTreeSection: some View { + if !group.subgroups.isEmpty { + Section("Groups") { + ForEach(group.subgroups) { sub in + NavigationLink(destination: GroupBrowserView(vm: vm, group: sub)) { + Label(sub.name, systemImage: "folder") + } + } + } + } + if !group.entries.isEmpty { + Section("Entries") { + ForEach(group.entries) { entry in + NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) { + EntryRow(entry: entry) + } + } + .onDelete { offsets in + offsets.map { group.entries[$0] }.forEach { vm.deleteEntry($0, fromGroup: group) } + } + } + } + } +} + +private struct EntryRow: View { + let entry: Entry + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title).font(.body) + Text(entry.username).font(.caption).foregroundStyle(.secondary) + } + } +} +``` + +- [ ] **Step 3: Build** `⌘B`. + +- [ ] **Step 4: Commit** + +```bash +git add MyPass/ViewModels/VaultViewModel.swift MyPass/Views/GroupBrowserView.swift +git commit -m "feat: add GroupBrowserView with search and group navigation" +``` + +--- + +### Task 14: EntryDetailView (with TOTP countdown) + +**Files:** +- Create: `MyPass/Views/EntryDetailView.swift` + +- [ ] **Step 1: Write EntryDetailView** + +```swift +// MyPass/Views/EntryDetailView.swift +import SwiftUI +import MyPassCore + +struct EntryDetailView: View { + let entry: Entry + let session: VaultSession + + @State private var showPassword = false + @State private var showEditSheet = false + + var body: some View { + List { + credentialSection + if !entry.url.isEmpty { urlSection } + if !entry.notes.isEmpty { notesSection } + if entry.totp != nil { totpSection } + if !entry.customFields.isEmpty { customFieldsSection } + if !entry.attachments.isEmpty { attachmentsSection } + } + .navigationTitle(entry.title) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Edit") { showEditSheet = true } + } + } + .sheet(isPresented: $showEditSheet) { + EntryEditView(vm: EntryEditViewModel(session: session, existing: entry)) + } + #if os(macOS) + .keyboardShortcut("e", modifiers: .command) + #endif + } + + private var credentialSection: some View { + Section("Credentials") { + FieldRow(label: "Username", value: entry.username, isCopyable: true) + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Password").font(.caption).foregroundStyle(.secondary) + Text(showPassword ? entry.password.reveal() : String(repeating: "•", count: 10)) + .font(.body.monospaced()) + } + Spacer() + Button { showPassword.toggle() } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + } + .buttonStyle(.plain) + Button { ClipboardService.copy(entry.password.reveal()) } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + } + } + } + + private var urlSection: some View { + Section("URL") { + FieldRow(label: "URL", value: entry.url, isCopyable: true) + } + } + + private var notesSection: some View { + Section("Notes") { + Text(entry.notes) + .font(.body) + } + } + + private var totpSection: some View { + Section("One-Time Password") { + if let config = entry.totp { + TOTPRow(config: config) + } + } + } + + private var customFieldsSection: some View { + Section("Custom Fields") { + ForEach(entry.customFields) { field in + FieldRow( + label: field.key, + value: field.value.reveal(), + isCopyable: true, + isProtected: field.value.isProtected + ) + } + } + } + + private var attachmentsSection: some View { + Section("Attachments") { + ForEach(entry.attachments) { att in + Label(att.name, systemImage: "paperclip") + } + } + } +} + +private struct FieldRow: View { + let label: String + let value: String + var isCopyable: Bool = false + var isProtected: Bool = false + @State private var revealed = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(isProtected && !revealed ? "••••••••" : value) + .font(.body) + } + Spacer() + if isProtected { + Button { revealed.toggle() } label: { + Image(systemName: revealed ? "eye.slash" : "eye") + }.buttonStyle(.plain) + } + if isCopyable { + Button { ClipboardService.copy(value) } label: { + Image(systemName: "doc.on.doc") + }.buttonStyle(.plain) + } + } + } +} + +private struct TOTPRow: View { + let config: TOTPConfig + @State private var code: String = "" + @State private var secondsLeft: Int = 30 + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("TOTP").font(.caption).foregroundStyle(.secondary) + Text(formattedCode).font(.title3.monospaced()).bold() + } + Spacer() + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 3) + Circle() + .trim(from: 0, to: CGFloat(secondsLeft) / CGFloat(config.period)) + .stroke(secondsLeft > 10 ? Color.green : Color.orange, lineWidth: 3) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: secondsLeft) + Text("\(secondsLeft)").font(.caption2) + } + .frame(width: 32, height: 32) + Button { ClipboardService.copy(code) } label: { + Image(systemName: "doc.on.doc") + }.buttonStyle(.plain) + } + .onReceive(timer) { _ in refresh() } + .onAppear { refresh() } + } + + private var formattedCode: String { + guard code.count == 6 else { return code } + return String(code.prefix(3)) + " " + String(code.suffix(3)) + } + + private func refresh() { + code = TOTPGenerator.generate(config: config) + secondsLeft = TOTPGenerator.secondsRemaining(config: config) + } +} +``` + +- [ ] **Step 2: Build** `⌘B`. + +- [ ] **Step 3: Commit** + +```bash +git add MyPass/Views/EntryDetailView.swift +git commit -m "feat: add EntryDetailView with TOTP countdown" +``` + +--- + +### Task 15: EntryEditViewModel + EntryEditView + +**Files:** +- Create: `MyPass/ViewModels/EntryEditViewModel.swift` +- Create: `MyPass/Views/EntryEditView.swift` + +- [ ] **Step 1: Write EntryEditViewModel** + +```swift +// MyPass/ViewModels/EntryEditViewModel.swift +import Foundation +import MyPassCore + +@MainActor +final class EntryEditViewModel: ObservableObject { + @Published var title: String + @Published var username: String + @Published var password: String + @Published var url: String + @Published var notes: String + @Published var customFields: [CustomField] + @Published var errorMessage: String? + @Published var isSaving: Bool = false + + private let session: VaultSession + private let groupId: UUID + private let existingEntry: Entry? + + init(session: VaultSession, groupId: UUID, existing: Entry? = nil) { + self.session = session + self.groupId = groupId + self.existingEntry = existing + title = existing?.title ?? "" + username = existing?.username ?? "" + password = existing?.password.reveal() ?? "" + url = existing?.url ?? "" + notes = existing?.notes ?? "" + customFields = existing?.customFields ?? [] + } + + var isEditing: Bool { existingEntry != nil } + + func save(dismiss: () -> Void) { + Task { + isSaving = true + errorMessage = nil + do { + if var entry = existingEntry { + entry.title = title + entry.username = username + entry.password = ProtectedString(password, isProtected: true) + entry.url = url + entry.notes = notes + entry.customFields = customFields + try session.updateEntry(entry) + } else { + let entry = Entry( + title: title, + username: username, + password: ProtectedString(password, isProtected: true), + url: url, + notes: notes, + customFields: customFields + ) + try session.addEntry(entry, toGroupId: groupId) + } + dismiss() + } catch { + errorMessage = error.localizedDescription + } + isSaving = false + } + } + + func addCustomField() { + customFields.append(CustomField(key: "", value: ProtectedString("", isProtected: false))) + } + + func removeCustomField(at offsets: IndexSet) { + customFields.remove(atOffsets: offsets) + } +} +``` + +- [ ] **Step 2: Write EntryEditView** + +```swift +// MyPass/Views/EntryEditView.swift +import SwiftUI +import MyPassCore + +struct EntryEditView: View { + @ObservedObject var vm: EntryEditViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Credentials") { + TextField("Title", text: $vm.title) + TextField("Username", text: $vm.username) + .textContentType(.username) + .autocorrectionDisabled() + SecureField("Password", text: $vm.password) + .textContentType(.password) + TextField("URL", text: $vm.url) + .textContentType(.URL) + .keyboardType(.URL) + .autocorrectionDisabled() + } + + Section("Notes") { + TextEditor(text: $vm.notes) + .frame(minHeight: 80) + } + + Section { + ForEach($vm.customFields) { $field in + HStack { + TextField("Key", text: $field.key) + Divider() + TextField("Value", text: Binding( + get: { field.value.reveal() }, + set: { field.value = ProtectedString($0, isProtected: field.value.isProtected) } + )) + } + } + .onDelete(perform: vm.removeCustomField) + Button("Add Field", action: vm.addCustomField) + } header: { + Text("Custom Fields") + } + + if let msg = vm.errorMessage { + Section { Text(msg).foregroundStyle(.red) } + } + } + .navigationTitle(vm.isEditing ? "Edit Entry" : "New Entry") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { vm.save { dismiss() } } + .disabled(vm.title.isEmpty || vm.isSaving) + } + } + } + } +} +``` + +- [ ] **Step 3: Build** `⌘B`. + +- [ ] **Step 4: Commit** + +```bash +git add MyPass/ViewModels/EntryEditViewModel.swift MyPass/Views/EntryEditView.swift +git commit -m "feat: add EntryEditView for add/edit entries" +``` + +--- + +### Task 16: SearchView + +**Files:** +- Create: `MyPass/Views/SearchView.swift` + +> Note: Search is embedded directly in `GroupBrowserView` via `.searchable`. `SearchView` is a standalone view for displaying search results when the query is active — used in the macOS 3-column layout as the middle column when searching. + +- [ ] **Step 1: Write SearchView** + +```swift +// MyPass/Views/SearchView.swift +import SwiftUI +import MyPassCore + +struct SearchView: View { + @ObservedObject var vm: VaultViewModel + @Binding var selectedEntry: Entry? + + var body: some View { + Group { + if vm.filteredEntries.isEmpty { + ContentUnavailableView.search(text: vm.searchQuery) + } else { + List(vm.filteredEntries, selection: $selectedEntry) { entry in + #if os(macOS) + EntryListRow(entry: entry).tag(entry) + #else + NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) { + EntryListRow(entry: entry) + } + #endif + } + } + } + } +} + +struct EntryListRow: View { + let entry: Entry + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title).font(.body) + Text(entry.username).font(.caption).foregroundStyle(.secondary) + if !entry.url.isEmpty { + Text(entry.url).font(.caption2).foregroundStyle(.tertiary) + } + } + } +} +``` + +- [ ] **Step 2: Build** `⌘B`. + +- [ ] **Step 3: Commit** + +```bash +git add MyPass/Views/SearchView.swift +git commit -m "feat: add SearchView" +``` + +--- + +### Task 17: MyPassApp + ContentView (routing, lifecycle, macOS layout) + +**Files:** +- Modify: `MyPass/MyPassApp.swift` +- Replace: `MyPass/ContentView.swift` + +- [ ] **Step 1: Rewrite ContentView as the app router** + +```swift +// MyPass/ContentView.swift +import SwiftUI +import MyPassCore + +struct ContentView: View { + @StateObject private var session = VaultSession() + + var body: some View { + Group { + if session.isLocked { + UnlockView(vm: UnlockViewModel(session: session)) + } else { + vaultView + } + } + .onReceive( + NotificationCenter.default.publisher(for: sceneBackgroundNotification) + ) { _ in + session.lock() + } + } + + @ViewBuilder + private var vaultView: some View { + let vaultVM = VaultViewModel(session: session) + #if os(macOS) + MacVaultView(vm: vaultVM) + #else + NavigationStack { + if let root = session.database?.root { + GroupBrowserView(vm: vaultVM, group: root) + } + } + #endif + } + + private var sceneBackgroundNotification: Notification.Name { + #if os(iOS) + UIScene.didEnterBackgroundNotification + #else + NSApplication.didResignActiveNotification + #endif + } +} + +// MARK: - macOS 3-column layout + +#if os(macOS) +private struct MacVaultView: View { + @ObservedObject var vm: VaultViewModel + @State private var selectedGroup: Group? + @State private var selectedEntry: Entry? + @State private var showAddEntry = false + + private var activeGroup: Group? { selectedGroup ?? vm.session.database?.root } + + var body: some View { + NavigationSplitView { + GroupSidebarView(vm: vm, selectedGroup: $selectedGroup) + } content: { + if vm.isSearching { + SearchView(vm: vm, selectedEntry: $selectedEntry) + } else if let group = activeGroup { + entryList(for: group) + } else { + Text("Select a group").foregroundStyle(.secondary) + } + } detail: { + if let entry = selectedEntry { + EntryDetailView(entry: entry, session: vm.session) + } else { + Text("Select an entry").foregroundStyle(.secondary) + } + } + .searchable(text: $vm.searchQuery, prompt: "Search all entries…") + .sheet(isPresented: $showAddEntry) { + if let group = activeGroup { + EntryEditView(vm: EntryEditViewModel(session: vm.session, groupId: group.id)) + } + } + } + + private func entryList(for group: Group) -> some View { + List(group.entries, selection: $selectedEntry) { entry in + EntryListRow(entry: entry).tag(entry) + } + .navigationTitle(group.name) + .toolbar { + ToolbarItem { + Button { showAddEntry = true } label: { Image(systemName: "plus") } + } + } + } +} + +private struct GroupSidebarView: View { + @ObservedObject var vm: VaultViewModel + @Binding var selectedGroup: Group? + + var body: some View { + List(selection: $selectedGroup) { + if let root = vm.session.database?.root { + GroupNode(group: root) + } + } + .navigationTitle("Groups") + } +} + +private struct GroupNode: View { + let group: Group + var body: some View { + if group.subgroups.isEmpty { + Label(group.name, systemImage: "folder").tag(group) + } else { + DisclosureGroup { + ForEach(group.subgroups) { sub in GroupNode(group: sub) } + } label: { + Label(group.name, systemImage: "folder").tag(group) + } + } + } +} +#endif +``` + +- [ ] **Step 2: Update MyPassApp.swift** + +```swift +// MyPass/MyPassApp.swift +import SwiftUI + +@main +struct MyPassApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +``` + +- [ ] **Step 3: Register the deep-link URL scheme** + +In Xcode → **MyPass** target → **Info** tab → add a URL Type: +- Identifier: `com.christophevila.mypass` +- URL Schemes: `mypass` + +- [ ] **Step 4: Build and run on simulator** + +`⌘R`. Tap **Open KDBX File…**, pick a test `.kdbx` file from Files or drag one into the simulator. Enter the master password. Verify the group/entry list appears. + +- [ ] **Step 5: Commit** + +```bash +git add MyPass/ContentView.swift MyPass/MyPassApp.swift +git commit -m "feat: wire ContentView router, macOS 3-column layout, scene lifecycle lock" +``` + +--- + +## Phase 5 — AutoFill Extension + +### Task 18: AutoFillViewController + +**Files:** +- Create: `AutoFillExtension/AutoFillViewController.swift` + +- [ ] **Step 1: Write AutoFillViewController** + +Replace the boilerplate `CredentialProviderViewController.swift` Xcode generated with: + +```swift +// AutoFillExtension/AutoFillViewController.swift +import AuthenticationServices +import SwiftUI +import MyPassCore + +final class AutoFillViewController: ASCredentialProviderViewController { + + private let session = VaultSession() + private let bookmarkService = FileBookmarkService() + private let keychainStore = KeychainStore(accessGroup: "com.christophevila.mypass") + private let biometricService = BiometricAuthService() + + // Called when the user selects MyPass from the QuickType bar. + override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { + let ids = serviceIdentifiers.map(\.identifier) + showUI(serviceIdentifiers: ids) + } + + // Called for inline Quick Type suggestion (no UI shown). + override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { + // If vault is already unlocked (biometric token valid), provide immediately. + Task { + do { + try await unlockSilently() + let all = session.allEntries() + if let entry = all.first(where: { $0.id.uuidString == credentialIdentity.recordIdentifier }) { + let credential = ASPasswordCredential(user: entry.username, password: entry.password.reveal()) + self.extensionContext.completeRequest(withSelectedCredential: credential, completionHandler: nil) + } else { + self.extensionContext.cancelRequest(withError: ASExtensionError(.credentialIdentityNotFound)) + } + } catch { + self.extensionContext.cancelRequest(withError: ASExtensionError(.userInteractionRequired)) + } + } + } + + private func unlockSilently() async throws { + guard !session.isLocked else { + let password = try keychainStore.load(for: "masterPassword") + let url = try bookmarkService.resolveURL() + defer { bookmarkService.stopAccess(url: url) } + try session.unlock(url: url, password: password) + return + } + } + + private func showUI(serviceIdentifiers: [String]) { + let rootView = ExtensionRootView( + session: session, + serviceIdentifiers: serviceIdentifiers, + bookmarkService: bookmarkService, + keychainStore: keychainStore, + biometricService: biometricService, + onSelect: { [weak self] entry in + let credential = ASPasswordCredential( + user: entry.username, + password: entry.password.reveal() + ) + self?.extensionContext.completeRequest(withSelectedCredential: credential, completionHandler: nil) + }, + onCancel: { [weak self] in + self?.extensionContext.cancelRequest(withError: ASExtensionError(.userCanceled)) + } + ) + let host = UIHostingController(rootView: rootView) + addChild(host) + view.addSubview(host.view) + host.view.frame = view.bounds + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.didMove(toParent: self) + } +} +``` + +- [ ] **Step 2: Build** `⌘B` (will fail until ExtensionRootView is created in Task 19). + +- [ ] **Step 3: Commit (WIP)** + +```bash +git add AutoFillExtension/AutoFillViewController.swift +git commit -m "feat: add AutoFillViewController skeleton" +``` + +--- + +### Task 19: ExtensionUnlockView + ExtensionRootView + +**Files:** +- Create: `AutoFillExtension/Views/ExtensionUnlockView.swift` + +- [ ] **Step 1: Write ExtensionUnlockView and ExtensionRootView** + +```swift +// AutoFillExtension/Views/ExtensionUnlockView.swift +import SwiftUI +import MyPassCore + +/// Root view for the extension — shows unlock screen or credential list depending on state. +struct ExtensionRootView: View { + @StateObject private var vm: ExtensionViewModel + + init( + session: VaultSession, + serviceIdentifiers: [String], + bookmarkService: FileBookmarkService, + keychainStore: KeychainStore, + biometricService: BiometricAuthService, + onSelect: @escaping (Entry) -> Void, + onCancel: @escaping () -> Void + ) { + _vm = StateObject(wrappedValue: ExtensionViewModel( + session: session, + serviceIdentifiers: serviceIdentifiers, + bookmarkService: bookmarkService, + keychainStore: keychainStore, + biometricService: biometricService, + onSelect: onSelect, + onCancel: onCancel + )) + } + + var body: some View { + NavigationStack { + Group { + if vm.isLocked { + extensionUnlockView + } else { + CredentialListView(vm: vm) + } + } + .navigationTitle("MyPass") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel", action: vm.cancel) + } + } + } + .task { await vm.tryBiometricUnlock() } + } + + private var extensionUnlockView: some View { + VStack(spacing: 24) { + Spacer() + Image(systemName: "lock.shield.fill").font(.system(size: 48)).foregroundStyle(.tint) + Text("Vault Locked").font(.headline) + + if vm.canUseBiometrics { + Button(action: { Task { await vm.tryBiometricUnlock() } }) { + Label("Use Face ID / Touch ID", systemImage: "faceid") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + + SecureField("Master Password", text: $vm.password) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await vm.unlockWithPassword() } } + + Button("Unlock") { Task { await vm.unlockWithPassword() } } + .buttonStyle(.bordered) + .disabled(vm.password.isEmpty || vm.isUnlocking) + + if let msg = vm.errorMessage { + Text(msg).foregroundStyle(.red).font(.caption).multilineTextAlignment(.center) + } + + if !vm.hasVault { + Link(destination: URL(string: "mypass://unlock")!) { + Label("Open MyPass to set up vault", systemImage: "arrow.up.right") + .font(.caption) + } + } + Spacer() + } + .padding(24) + } +} + +@MainActor +final class ExtensionViewModel: ObservableObject { + @Published var password: String = "" + @Published var errorMessage: String? + @Published var isUnlocking: Bool = false + + let session: VaultSession + let serviceIdentifiers: [String] + private let bookmarkService: FileBookmarkService + private let keychainStore: KeychainStore + private let biometricService: BiometricAuthService + private let onSelect: (Entry) -> Void + private let onCancel: () -> Void + + init( + session: VaultSession, + serviceIdentifiers: [String], + bookmarkService: FileBookmarkService, + keychainStore: KeychainStore, + biometricService: BiometricAuthService, + onSelect: @escaping (Entry) -> Void, + onCancel: @escaping () -> Void + ) { + self.session = session + self.serviceIdentifiers = serviceIdentifiers + self.bookmarkService = bookmarkService + self.keychainStore = keychainStore + self.biometricService = biometricService + self.onSelect = onSelect + self.onCancel = onCancel + } + + var isLocked: Bool { session.isLocked } + var hasVault: Bool { bookmarkService.hasBookmark } + var canUseBiometrics: Bool { biometricService.isAvailable && keychainStore.exists(for: "masterPassword") } + + var suggestedEntries: [Entry] { + CredentialMatcher.filter(entries: session.allEntries(), for: serviceIdentifiers).suggested + } + + var allEntries: [Entry] { + CredentialMatcher.filter(entries: session.allEntries(), for: serviceIdentifiers).all + } + + func select(_ entry: Entry) { onSelect(entry) } + func cancel() { onCancel() } + + func tryBiometricUnlock() async { + guard canUseBiometrics, session.isLocked else { return } + isUnlocking = true + do { + try await biometricService.authenticate(reason: "Unlock MyPass") + try performUnlockFromKeychain() + } catch { + // Silently fail — user can type password + } + isUnlocking = false + } + + func unlockWithPassword() async { + isUnlocking = true + errorMessage = nil + do { + let url = try bookmarkService.resolveURL() + defer { bookmarkService.stopAccess(url: url) } + try session.unlock(url: url, password: password) + try keychainStore.save(password: password, for: "masterPassword") + password = "" + } catch KDBXError.invalidPassword { + errorMessage = "Incorrect password." + } catch { + errorMessage = error.localizedDescription + } + isUnlocking = false + } + + private func performUnlockFromKeychain() throws { + let pw = try keychainStore.load(for: "masterPassword") + let url = try bookmarkService.resolveURL() + defer { bookmarkService.stopAccess(url: url) } + try session.unlock(url: url, password: pw) + } +} +``` + +- [ ] **Step 2: Build** `⌘B`. + +- [ ] **Step 3: Commit** + +```bash +git add AutoFillExtension/Views/ExtensionUnlockView.swift +git commit -m "feat: add ExtensionRootView and ExtensionViewModel" +``` + +--- + +### Task 20: CredentialListView (extension) + +**Files:** +- Create: `AutoFillExtension/Views/CredentialListView.swift` + +- [ ] **Step 1: Write CredentialListView** + +```swift +// AutoFillExtension/Views/CredentialListView.swift +import SwiftUI +import MyPassCore + +struct CredentialListView: View { + @ObservedObject var vm: ExtensionViewModel + @State private var searchQuery: String = "" + + private var displayedSuggested: [Entry] { + searchQuery.isEmpty ? vm.suggestedEntries : [] + } + + private var displayedAll: [Entry] { + let entries = searchQuery.isEmpty ? vm.allEntries : vm.session.allEntries() + guard !searchQuery.isEmpty else { return entries } + let q = searchQuery.lowercased() + return entries.filter { + $0.title.lowercased().contains(q) + || $0.username.lowercased().contains(q) + || $0.url.lowercased().contains(q) + } + } + + var body: some View { + List { + if !displayedSuggested.isEmpty { + Section("Suggested") { + ForEach(displayedSuggested) { entry in + CredentialRow(entry: entry) { vm.select(entry) } + } + } + } + Section(displayedSuggested.isEmpty ? "" : "All Entries") { + if displayedAll.isEmpty { + Text("No entries found").foregroundStyle(.secondary) + } else { + ForEach(displayedAll) { entry in + CredentialRow(entry: entry) { vm.select(entry) } + } + } + } + } + .searchable(text: $searchQuery, prompt: "Search…") + } +} + +private struct CredentialRow: View { + let entry: Entry + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title).font(.body).foregroundStyle(.primary) + Text(entry.username).font(.caption).foregroundStyle(.secondary) + if !entry.url.isEmpty { + Text(entry.url).font(.caption2).foregroundStyle(.tertiary) + } + } + } + } +} +``` + +- [ ] **Step 2: Build and run on simulator** + +`⌘R`. Open **Settings → Passwords → AutoFill Passwords** on the simulator. Enable **MyPass**. Open Safari and navigate to a site that matches an entry's URL. Tap a password field → the QuickType bar should show MyPass. Tap it → the extension UI appears with matching credentials. + +- [ ] **Step 3: Final commit** + +```bash +git add AutoFillExtension/Views/CredentialListView.swift +git commit -m "feat: add CredentialListView for AutoFill extension" +``` + +--- + +## Done + +At this point: +- `MyPassCore` package holds all KDBX, TOTP, Keychain, and AutoFill matching logic +- The main app opens `.kdbx` files, unlocks with Face ID or password, and supports full CRUD on entries and groups +- The AutoFill extension fills credentials in every app via `ASCredentialProviderViewController` +- Unit tests cover `ProtectedString`, `TOTPGenerator`, `CredentialMatcher`, and KDBX round-trips + +## Known Limitations (follow-up work) + +- **Save-conflict detection:** The spec calls for `NSFileCoordinator` to detect external modifications to the KDBX file. `KDBXDocument.write` should be wrapped in an `NSFileCoordinator.coordinate(writingItemAt:options:error:byAccessor:)` call, and reads should use the read variant. Implement as a follow-up once core CRUD is working. +- **Attachment add/edit:** `EntryDetailView` displays attachments (Task 14) but `EntryEditView` does not yet support adding/removing attachment files. Wire up a `fileImporter` to `EntryEditView`'s custom fields section to add this. +- **macOS keyboard shortcuts:** `⌘C` → copy password and `⌘⌥C` → copy TOTP in `EntryDetailView` on macOS. Add `.keyboardShortcut("c", modifiers: .command)` and `.keyboardShortcut("c", modifiers: [.command, .option])` to the respective copy buttons.