Files
keevault/docs/superpowers/plans/2026-05-21-mypass.md
T
2026-05-21 23:04:24 +02:00

2761 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.