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 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:11:14 +02:00
co-authored by Claude Sonnet 4.6
parent 45d7441636
commit 9bb506c5e0
6 changed files with 211 additions and 1 deletions
@@ -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)
}
}
}
@@ -0,0 +1,8 @@
import Foundation
public enum KDBXError: Error, Equatable {
case invalidPassword
case fileNotFound
case parseError(String)
case writeError(String)
}
@@ -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()
}
}
Binary file not shown.
@@ -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" })
}
}