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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user