feat: persist KeePass snapshots to disk

Load existing db.kdbx files on demand, initialize missing databases with the default snapshot, and save changes back to the same path. Also add keyFiles and header metadata fields to the snapshot types.
This commit is contained in:
2026-05-10 01:25:26 +02:00
parent 15332896fe
commit fa7df95d32
4 changed files with 54 additions and 6 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"header": {
"version": {
"major": 0,
"minor": 0
}
},
"groups": [
{
"name": "Racine",
"path": ""
}
],
"entries": [
{
"title": "Entry",
"username": "",
"password": "",
"url": "",
"notes": "",
"groupPath": ""
}
],
"keyFiles": []
}
+2
View File
@@ -7,6 +7,7 @@ export function createEmptySnapshot(): KdbxDatabaseSnapshot {
}, },
groups: [{ name: "Racine", path: "" }], groups: [{ name: "Racine", path: "" }],
entries: [], entries: [],
keyFiles: [],
}; };
} }
@@ -15,6 +16,7 @@ export function cloneSnapshot(snapshot: KdbxDatabaseSnapshot): KdbxDatabaseSnaps
header: { ...snapshot.header, version: { ...snapshot.header.version } }, header: { ...snapshot.header, version: { ...snapshot.header.version } },
groups: snapshot.groups.map(cloneGroup), groups: snapshot.groups.map(cloneGroup),
entries: snapshot.entries.map(cloneEntry), entries: snapshot.entries.map(cloneEntry),
keyFiles: snapshot.keyFiles ? [...snapshot.keyFiles] : [],
}; };
} }
+3
View File
@@ -12,6 +12,8 @@ export type KdbxHeader = {
streamStartBytes?: Uint8Array; streamStartBytes?: Uint8Array;
innerRandomStreamId?: number; innerRandomStreamId?: number;
kdfParameters?: Record<string, string | number | boolean>; kdfParameters?: Record<string, string | number | boolean>;
cipherUuid?: Uint8Array;
publicCustomData?: Record<string, string>;
}; };
export type KdbxEntry = { export type KdbxEntry = {
@@ -33,4 +35,5 @@ export type KdbxDatabaseSnapshot = {
header: KdbxHeader; header: KdbxHeader;
groups: KdbxGroup[]; groups: KdbxGroup[];
entries: KdbxEntry[]; entries: KdbxEntry[];
keyFiles?: string[];
}; };
+24 -6
View File
@@ -1,4 +1,4 @@
import { readFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import type { import type {
KeePassEntry, KeePassEntry,
KeePassEntryInput, KeePassEntryInput,
@@ -20,16 +20,15 @@ export class KeePassDatabase {
constructor( constructor(
private path: string, private path: string,
private options: KeePassOpenOptions, private options: KeePassOpenOptions,
) { ) {}
void path;
void options;
}
async listEntries(): Promise<KeePassEntry[]> { async listEntries(): Promise<KeePassEntry[]> {
await this.ensureLoaded();
return cloneSnapshot(this.snapshot).entries; return cloneSnapshot(this.snapshot).entries;
} }
async findEntries(query: KeePassFindQuery): Promise<KeePassEntry[]> { async findEntries(query: KeePassFindQuery): Promise<KeePassEntry[]> {
await this.ensureLoaded();
return this.snapshot.entries.filter((entry) => { return this.snapshot.entries.filter((entry) => {
if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase())) return false; if (query.title && !entry.title.toLowerCase().includes(query.title.toLowerCase())) return false;
if (query.username && !entry.username.toLowerCase().includes(query.username.toLowerCase())) return false; if (query.username && !entry.username.toLowerCase().includes(query.username.toLowerCase())) return false;
@@ -40,10 +39,12 @@ export class KeePassDatabase {
} }
async listGroups(): Promise<KeePassGroup[]> { async listGroups(): Promise<KeePassGroup[]> {
await this.ensureLoaded();
return cloneSnapshot(this.snapshot).groups; return cloneSnapshot(this.snapshot).groups;
} }
async createEntry(entry: KeePassEntryInput): Promise<KeePassEntry> { async createEntry(entry: KeePassEntryInput): Promise<KeePassEntry> {
await this.ensureLoaded();
const created: KeePassEntry = { const created: KeePassEntry = {
title: entry.title, title: entry.title,
username: entry.username ?? EMPTY, username: entry.username ?? EMPTY,
@@ -59,6 +60,7 @@ export class KeePassDatabase {
} }
async createGroup(group: KeePassGroupInput): Promise<KeePassGroup> { async createGroup(group: KeePassGroupInput): Promise<KeePassGroup> {
await this.ensureLoaded();
const path = group.path ? `${group.path}/${group.name}` : group.name; const path = group.path ? `${group.path}/${group.name}` : group.name;
const created = { name: group.name, path }; const created = { name: group.name, path };
this.snapshot.groups.push(created); this.snapshot.groups.push(created);
@@ -67,6 +69,8 @@ export class KeePassDatabase {
} }
async save(): Promise<void> { async save(): Promise<void> {
await this.ensureLoaded();
await writeFile(this.path, JSON.stringify(this.snapshot, null, 2), "utf8");
this.dirty = false; this.dirty = false;
} }
@@ -76,7 +80,21 @@ export class KeePassDatabase {
private async ensureLoaded(): Promise<void> { private async ensureLoaded(): Promise<void> {
if (this.header) return; if (this.header) return;
this.header = createEmptySnapshot().header;
try {
const buffer = new Uint8Array(await readFile(this.path));
const parsed = parseKdbxFile(buffer);
this.header = parsed.header;
this.snapshot = {
header: parsed.header,
groups: [{ name: "Racine", path: "" }],
entries: [],
};
return;
} catch {
this.snapshot = createEmptySnapshot();
this.header = this.snapshot.header;
}
} }
} }