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 0000000..08fa7cd Binary files /dev/null and b/MyPassCore/Tests/MyPassCoreTests/Fixtures/test.kdbx differ 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