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