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:
@@ -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" })
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule Vendor/KeePassKit updated: 4dbd8cd7bc...921cca3e57
Reference in New Issue
Block a user