From 9bb506c5e07efc623cf8611e96c742c08217dc0d Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 22 May 2026 14:11:14 +0200 Subject: [PATCH] feat: add KDBXDocument with KeePassKit-backed KDBX parsing Implements KDBXError, KDBXMapper, and KDBXDocument for reading and writing KDBX files using KeePassKit. Adds KDBXDocumentTests with fixture from KeePassKit's own test suite (Test_Password_1234.kdbx, password "1234"). Fixes KeePassKit Package.swift to include defaultLocalization for SPM compatibility. Co-Authored-By: Claude Sonnet 4.6 --- .../MyPassCore/KDBX/KDBXDocument.swift | 45 +++++++ .../Sources/MyPassCore/KDBX/KDBXError.swift | 8 ++ .../Sources/MyPassCore/KDBX/KDBXMapper.swift | 121 ++++++++++++++++++ .../Tests/MyPassCoreTests/Fixtures/test.kdbx | Bin 0 -> 1406 bytes .../MyPassCoreTests/KDBXDocumentTests.swift | 36 ++++++ Vendor/KeePassKit | 2 +- 6 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift create mode 100644 MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift create mode 100644 MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift create mode 100644 MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx create mode 100644 MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift diff --git a/MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift b/MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift new file mode 100644 index 0000000..5de7dda --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/KDBX/KDBXDocument.swift @@ -0,0 +1,45 @@ +import Foundation +import KeePassKit + +public struct KDBXDocument { + public let url: URL + + public init(url: URL) { + self.url = url + } + + /// Reads a KDBX database from disk and maps it to a KDBXDatabase value type. + public func read(password: String) throws -> KDBXDatabase { + guard FileManager.default.fileExists(atPath: url.path) else { + throw KDBXError.fileNotFound + } + let key = KPKCompositeKey(keys: [KPKKey.init(password: password)]) + var error: NSError? + guard let tree = KPKTree(contentsOfUrl: url, key: key, error: &error) else { + if let err = error { + if err.domain == KPKErrorDomain && err.code == KPKErrorCode.passwordAndOrKeyfileWrong.rawValue { + throw KDBXError.invalidPassword + } + throw KDBXError.parseError(err.localizedDescription) + } + throw KDBXError.parseError("Unknown error reading KDBX file") + } + return KDBXMapper.database(from: tree) + } + + /// Writes a KDBXDatabase value type to disk as a KDBX file. + public func write(_ database: KDBXDatabase, password: String) throws { + let tree = KDBXMapper.tree(from: database) + let key = KPKCompositeKey(keys: [KPKKey.init(password: password)]) + var error: NSError? + guard let data = tree.encrypt(with: key, format: .kdbx, error: &error) else { + let description = error?.localizedDescription ?? "Unknown error writing KDBX file" + throw KDBXError.writeError(description) + } + do { + try data.write(to: url, options: .atomic) + } catch { + throw KDBXError.writeError(error.localizedDescription) + } + } +} diff --git a/MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift b/MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift new file mode 100644 index 0000000..b33214a --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/KDBX/KDBXError.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum KDBXError: Error, Equatable { + case invalidPassword + case fileNotFound + case parseError(String) + case writeError(String) +} diff --git a/MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift b/MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift new file mode 100644 index 0000000..bb26d22 --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/KDBX/KDBXMapper.swift @@ -0,0 +1,121 @@ +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 { + // g.groups: direct child groups (not recursive) + // g.entries: direct child entries (not recursive) + Group( + id: uuid(from: g.uuid), + name: g.title ?? "", + iconIndex: Int(g.iconId), + subgroups: g.groups.map { group(from: $0) }, + entries: g.entries.map { entry(from: $0) } + ) + } + + static func entry(from e: KPKEntry) -> Entry { + // e.customAttributes: only non-default attributes + let customFields: [CustomField] = e.customAttributes.map { attr in + CustomField( + id: UUID(), + key: attr.key, + value: ProtectedString(attr.value, isProtected: attr.protect) + ) + } + + // Parse TOTP from the otp URI attribute (KeePassOTP format) + let totpURI = e.customAttributes.first { $0.key == "otp" }?.value + let totpConfig = totpURI.flatMap { TOTPConfig.parse(from: $0) } + + let attachments: [Attachment] = e.binaries.map { bin in + Attachment(id: UUID(), name: bin.name, data: bin.data) + } + + let historyEntries: [Entry] = e.history.map { entry(from: $0) } + + 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: e.tags ?? [], + iconIndex: Int(e.iconId), + expiryDate: e.timeInfo?.expires == true ? e.timeInfo?.expirationDate : nil, + creationDate: e.timeInfo?.creationDate ?? Date(), + modificationDate: e.timeInfo?.modificationDate ?? Date(), + history: historyEntries + ) + } + + // MARK: - MyPassCore → KeePassKit + + static func tree(from db: KDBXDatabase) -> KPKTree { + let tree = KPKTree() + let root = kpkGroup(from: db.root) + tree.root = 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.title = g.name + kpk.iconId = g.iconIndex + for sub in g.subgroups { + kpkGroup(from: sub).addToGroup(kpk) + } + for e in g.entries { + kpkEntry(from: e).addToGroup(kpk) + } + 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 = 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(name: att.name, data: 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() + } +} diff --git a/MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx b/MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..08fa7cdf8119e63b8ee0489f5f58c7788bcb6575 GIT binary patch literal 1406 zcmV-^1%dhl*`k_f`%AR}00RI55CAd3^5(yBLr}h01tDtuTK@wC0096100bZa7bRR- zHgvl?@Ek_=&V6F{8B}|h*w8|YUc{4@LO>rN1t0+Wno$UqMmUCmZ{x)kSM7u=QJUcr zLTq2_2*=J7nZVfw2mnyS00000000LN0E~@U@zVWuZ|Y!bOi3um*9ag0T@v*Q`V)%& zyI-vwj(Jamv_)b)9PHr z*N&B-+?pI6=ZLD;F09El>+DY~j+(sY9AP|icj?eTmj6UbD+9Em(Hl0txXJdk*~^(@ zAoi9$$5CQ!L34GFR3x;DZ>40GU;Q3&kSNsqypLsz13{(fwFB7lPSJlksZwz1-e{67=X>@W| z5ERh~qKP3n-Z=Bp{tQz&Fg{~Kq9yG4iLT@yQC+c7d}pU4itMhO-NM4?!U1`MIE3TN zv?pD}>^CqA?*)GwG5DEo=ErcRWD7Gh9_@QMtg@(4^`f;(hRpsSK;c_>q1tFMC!KnN zV*)#%f>2Df)v7YJv!eR9E5S9Ja$7$Oi+>(MIW^9H-Ql^a4&*kdoUVD+cL6>H6FE>t zC^*NWKH$2hXZ4pgYGC4G-OIgyjC&!l(Kf>lpy^V+GPk229t=8ap9sqg8404z6bajC z!Oasd;st*I3k?908zY@b0?=3YCD=ztZQxeE*9y}LOh!L~5p#)_RDC7;OU{O6O2Bj0 z0kQNu-~q{L&{5hyG9BQ0gWgx@2U@r#wLuVTDc;m-fW~lE??Y#WxzE$q8rPJ3g)&u> zeIXuVYo&n=!pPF$EJwOi1GQ7{p0+cz+h?%K7NzC#Ld`O5h{A??5?!V;qv#ByB7ATe z7!>?Y9(Zz$`9{v(EZd%pj~fpr2Z$ydxB)wMQ{Eol`M+Z+z$b$QMPaOS>tqTiZgL&# zM_8eDZWz%p6%)6-Ecbi4&Z;}2hLs;k>Fu5sHZGO@OX(mtk2@*^N!l$SFF;C4X7~vm zfUaWz)U`cV^FL6=ttKyvVeAayIYB;RncRMr|GrJ}R@%+-cQV;KMWU=hA))@War7k_ z{r*+y4x|Z`iBCRxiuMyDj)CO-O-%)R=LH@L zvdO6XNi_^4#+13Y0=)j&n|)9W8U&fp$U9`ZUm)A&4~s3@e&J`76dgO7xYB2?^sDsa z=F7%3R0!87(nC7i2{+74auzw+q1alRmM|}IgH~-RdM5lw{z(!XO)x*hE9iEy6sfe^ zn0-DN(hm@<=mvE?h?`!Na9}7f^J*d&Ad0JJrmlZhzEn6)-2_Orb`jK;m*9J&z$OZlvmE|V)nSh zFs~rku1Jq(4Nnm1ky`I-bsYNfWy7HJWVjwEI7z^8X)@*c^&@Z-yYwbPB@Vqg+Uz)y z4Qzc^V$h6ruPZEvbp~E0L^oZMt`rSkX^A};y}-bhS!5n(xa!Je9;@Rne2Q?pWGq4d z!NqgCymXYkLXTc8VAl}~&%*BgS`dV!g<_LF3gFi^H1;fu1_XwdO@yFo&>*i?`mtG% Mfjha6^*hdxUuu1kS^xk5 literal 0 HcmV?d00001 diff --git a/MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift b/MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift new file mode 100644 index 0000000..5926181 --- /dev/null +++ b/MyPassCore/Tests/MyPassCoreTests/KDBXDocumentTests.swift @@ -0,0 +1,36 @@ +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: "1234") + 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: "1234") + 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: "1234") + let reloaded = try KDBXDocument(url: tmpURL).read(password: "1234") + XCTAssertTrue(reloaded.root.entries.contains { $0.title == "RoundTripTest" }) + } +} diff --git a/Vendor/KeePassKit b/Vendor/KeePassKit index 4dbd8cd..921cca3 160000 --- a/Vendor/KeePassKit +++ b/Vendor/KeePassKit @@ -1 +1 @@ -Subproject commit 4dbd8cd7bc21bc3a548af5fbde3973a9d962967a +Subproject commit 921cca3e5728f57ed71d6a15319ce6d788ac1f80