Files

2761 lines
85 KiB
Markdown
Raw Permalink Normal View History

# 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<String> = ["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<Insecure.SHA1>.authenticationCode(for: counterBytes, using: symKey))
case .sha256:
hmacBytes = Array(HMAC<SHA256>.authenticationCode(for: counterBytes, using: symKey))
case .sha512:
hmacBytes = Array(HMAC<SHA512>.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 23.
- [ ] **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.