Initial commit

This commit is contained in:
2026-05-09 23:50:24 +02:00
commit 0d25e52ebc
21 changed files with 867 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
**/node_modules/
# Python virtualenv
.venv/
venv/
.env
.env.*
# Build outputs
dist/
build/
coverage/
*.tsbuildinfo
# Logs
*.log
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Python cache
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
# Local project memory/state
.memory/
+58
View File
@@ -0,0 +1,58 @@
# kdbx-lib
TypeScript wrapper around `pykeepass` for read-only access to KeePass `.kdbx` files.
## Architecture
- Public API: TypeScript
- Runtime backend: Python 3
- Bridge: `src/python/bridge.py`
- Transport: JSON over stdin/stdout
- Backend library: `pykeepass`
## Requirements
- Node.js or Bun
- Python 3
- `pykeepass` installed in the Python environment used by the bridge
- A project-local `.venv` works well
## Python setup
Install dependencies with:
```bash
bun run setup:python
```
Manual alternative:
```bash
python3 -m pip install pykeepass
```
## Core behavior
- Read-only library; it does not modify databases.
- `openKeePassDatabase(path, options)` opens a database through the Python bridge.
- `listEntries()` returns all entry fields exposed by the bridge: `title`, `username`, `password`, `url`, `notes`, `groupPath`, and `otp` when present.
- `findEntries(query)` performs partial matching and returns full entries.
- `listGroups()` returns group names and paths.
- `close()` is currently a no-op.
## Fixture facts
- Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`
- Fixture passwords and expected content live in companion JSON files
- `data.kdbx` contains four entries: `root`, `otp1`, `f1-item1`, `f2-item1`
- The fixture tree is `Racine/ -> root, otp1, Folder1/ -> f1-item1, Folder2/ -> f2-item1`
- Integration tests cover entries, groups, and the `otp1` OTP/TOTP value
- Canonical OTP value is the full `otpauth://...` URI returned by `pykeepass`
## Notes
- The bridge currently launches a Python process per call; simple but expensive.
- Errors from the bridge are propagated to TypeScript, including exit code when available.
- The API is still flatter than the real KeePass model.
- More failure-path tests are needed.
- Future improvement: a persistent Python process if performance becomes important.
+24
View File
@@ -0,0 +1,24 @@
# State
## Current focus
Read-only TypeScript wrapper around `pykeepass` via a Python JSON bridge.
## Current API
- `openKeePassDatabase(path, options)`
- `listEntries()`
- `findEntries(query)`
- `listGroups()`
- `close()` is a no-op
## Runtime model
- TypeScript starts the Python bridge
- Python uses `pykeepass`
- JSON is exchanged over stdin/stdout
- Bridge errors and empty/invalid JSON are surfaced to TypeScript
## Current fixture/test status
- Bundled fixtures: `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`
- Integration tests validate entries, groups, and OTP/TOTP output for `data.kdbx`
## Next step
Keep tightening failure-path coverage and improve the API shape only if needed.
+86
View File
@@ -0,0 +1,86 @@
# kdbx-lib
TypeScript wrapper around `pykeepass` for reading KeePass `.kdbx` files.
## Overview
This project uses:
- TypeScript as the public API
- Python as the runtime backend
- `pykeepass` to read KeePass databases
The TypeScript layer launches a Python bridge and exchanges JSON through stdin/stdout.
## Requirements
- Node.js or Bun
- Python 3
- `pykeepass` installed in the Python environment used by the bridge (the project provides `bun run setup:python`)
## Python setup
Install `pykeepass` in the Python environment used by the bridge:
```bash
bun run setup:python
```
If you prefer manual installation:
```bash
python3 -m pip install pykeepass
```
The bridge also works with a project-local virtual environment such as `.venv` if you want to pin Python dependencies.
## Usage
```ts
import { openKeePassDatabase } from "./src/keepass";
const db = openKeePassDatabase("tests/fixtures/data.kdbx", {
password: "123",
});
const entries = await db.listEntries();
console.log(entries);
```
## Example
Run the example using the bundled data fixture credentials:
```bash
bun run src/example.ts
```
## API
### `openKeePassDatabase(path, options)`
Creates a database wrapper.
#### Options
- `password`: KeePass master password, read from the fixture JSON in examples/tests when applicable
- `keyFile`: optional key file path
### `listEntries()`
Returns all entries in the database, with every entry field exposed by the bridge (`title`, `username`, `password`, `url`, `notes`, `groupPath`, `otp` when present).
### `findEntries(query)`
Finds entries by partial match and returns the full entry objects.
### `listGroups()`
Returns all groups, including their names and paths.
### `close()`
No-op for now.
## Notes
- The bridge currently launches a Python process per call.
- This is simple and robust for a first version.
- Errors from the Python bridge are propagated to the TypeScript API with the bridge exit code when available.
- A persistent Python process can be added later if needed.
- Bundled fixtures include `tests/fixtures/data.kdbx` and `tests/fixtures/empty.kdbx`; their companion JSON files store the password and expected content for tests/examples.
- Integration tests validate the bundled `data.kdbx` entry-by-entry and group-by-group against `tests/fixtures/data.kdbx.json`.
+22
View File
@@ -0,0 +1,22 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "kdbx-lib",
"devDependencies": {
"bun-types": "^1.1.0",
"typescript": "^5.5.0",
},
},
},
"packages": {
"@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "kdbx-lib",
"packageManager": "bun@1.0.0",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"example": "bun run src/example.ts",
"validate": "bun run test",
"test": "bun test",
"test:unit": "bun test",
"test:integration": "bun run src/test-integration.ts",
"setup:python": "python3 -m pip install pykeepass"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.5.0",
"bun-types": "^1.1.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { readFile } from "node:fs/promises";
import { openKeePassDatabase } from "./keepass";
async function main() {
const { password } = JSON.parse(await readFile("tests/fixtures/data.kdbx.json", "utf8")) as { password: string };
const db = openKeePassDatabase("tests/fixtures/data.kdbx", { password });
const entries = await db.listEntries();
console.log(entries);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
export { KeePassDatabase, openKeePassDatabase } from "./keepass";
export type {
KeePassCommand,
KeePassEntry,
KeePassFindQuery,
KeePassGroup,
KeePassOpenOptions,
KeePassResponse,
} from "./types";
+110
View File
@@ -0,0 +1,110 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import type {
KeePassCommand,
KeePassEntry,
KeePassFindQuery,
KeePassGroup,
KeePassOpenOptions,
KeePassResponse,
} from "./types";
export class KeePassDatabase {
constructor(
private readonly path: string,
private readonly options: KeePassOpenOptions,
private readonly pythonPath = "python3",
private readonly bridgePath = new URL("./python/bridge.py", import.meta.url)
) {}
async listEntries(): Promise<KeePassEntry[]> {
const response = await this.run<KeePassEntry[]>({ command: "list-entries" });
return response;
}
async findEntries(query: KeePassFindQuery): Promise<KeePassEntry[]> {
const response = await this.run<KeePassEntry[]>({ command: "find-entries", query });
return response;
}
async listGroups(): Promise<KeePassGroup[]> {
const response = await this.run<KeePassGroup[]>({ command: "list-groups" });
return response;
}
async close(): Promise<void> {
// No persistent process is kept alive yet.
return;
}
private async run<T>(command: KeePassCommand): Promise<T> {
const payload = JSON.stringify({
...command,
path: this.path,
password: this.options.password,
keyFile: this.options.keyFile,
});
const bridgeFile = fileURLToPath(this.bridgePath);
const result = await new Promise<{ stdout: string; stderr: string; code: number }>((resolve, reject) => {
const child = spawn(this.pythonPath, [bridgeFile], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
try {
child.stdin.write(payload);
child.stdin.end();
} catch (error) {
reject(error);
}
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
if (!settled) {
settled = true;
reject(error);
}
});
child.on("close", (code) => {
if (!settled) {
settled = true;
resolve({ stdout, stderr, code: code ?? 1 });
}
});
});
const output = result.stdout.trim();
if (!output) {
throw new Error(result.stderr || `Empty response from Python bridge (exit code ${result.code})`);
}
let parsed: KeePassResponse<T>;
try {
parsed = JSON.parse(output) as KeePassResponse<T>;
} catch (error) {
throw new Error(`Invalid JSON from Python bridge: ${error instanceof Error ? error.message : String(error)}`);
}
if (!parsed.ok) {
throw new Error(parsed.error || result.stderr || `KeePass bridge error (exit code ${result.code})`);
}
return parsed.data;
}
}
export function openKeePassDatabase(path: string, options: KeePassOpenOptions) {
return new KeePassDatabase(path, options);
}
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import json
import sys
from pathlib import Path
try:
from pykeepass import PyKeePass
except Exception as exc:
print(json.dumps({"ok": False, "error": f"Failed to import pykeepass: {exc}"}))
sys.exit(1)
def path_to_string(path):
if path is None:
return ""
if isinstance(path, str):
return path
if isinstance(path, (list, tuple)):
parts = []
for segment in path:
if hasattr(segment, "name") and segment.name:
parts.append(segment.name)
elif segment:
parts.append(str(segment))
return "/".join(parts)
return str(path)
def entry_to_dict(entry):
result = {
"title": entry.title or "",
"username": entry.username or "",
"password": entry.password or "",
"url": entry.url or "",
"notes": entry.notes or "",
"groupPath": path_to_string(entry.group.path if entry.group else ""),
}
otp = getattr(entry, "otp", None)
if otp:
result["otp"] = otp
return result
def group_to_dict(group):
return {
"name": group.name or "",
"path": path_to_string(group.path),
}
def load_db(db_path, password, key_file=None):
if key_file:
return PyKeePass(db_path, password=password, keyfile=key_file)
return PyKeePass(db_path, password=password)
def read_payload():
if len(sys.argv) > 1 and sys.argv[1].strip():
return json.loads(sys.argv[1])
return json.load(sys.stdin)
def emit(payload):
print(json.dumps(payload), flush=True)
def main():
try:
payload = read_payload()
except Exception as exc:
emit({"ok": False, "error": f"Invalid input payload: {exc}"})
return 1
command = payload.get("command")
db_path = payload.get("path")
password = payload.get("password")
key_file = payload.get("keyFile")
if not db_path or not password:
emit({"ok": False, "error": "Missing path or password"})
return 1
if not Path(db_path).exists():
emit({"ok": False, "error": f"Database not found: {db_path}"})
return 1
try:
db = load_db(db_path, password, key_file)
if command == "list-entries":
entries = [entry_to_dict(entry) for entry in db.entries]
emit({"ok": True, "data": entries})
return 0
if command == "find-entries":
query = payload.get("query", {})
results = []
for entry in db.entries:
entry_group_path = path_to_string(entry.group.path if entry.group else "")
if query.get("title") and query["title"] not in (entry.title or ""):
continue
if query.get("username") and query["username"] not in (entry.username or ""):
continue
if query.get("url") and query["url"] not in (entry.url or ""):
continue
if query.get("groupPath") and query["groupPath"] not in entry_group_path:
continue
results.append(entry_to_dict(entry))
emit({"ok": True, "data": results})
return 0
if command == "list-groups":
groups = []
for group in db.groups:
groups.append(group_to_dict(group))
emit({"ok": True, "data": groups})
return 0
emit({"ok": False, "error": f"Unknown command: {command}"})
return 1
except Exception as exc:
emit({"ok": False, "error": str(exc)})
return 1
if __name__ == "__main__":
raise SystemExit(main())
+16
View File
@@ -0,0 +1,16 @@
import { readFile } from "node:fs/promises";
import { openKeePassDatabase } from "./keepass";
async function main() {
const { password } = JSON.parse(await readFile("tests/fixtures/data.kdbx.json", "utf8")) as { password: string };
const db = openKeePassDatabase("tests/fixtures/data.kdbx", { password });
const entries = await db.listEntries();
console.log(JSON.stringify({ ok: true, count: entries.length }, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+41
View File
@@ -0,0 +1,41 @@
export type KeePassEntry = {
title: string;
username: string;
password: string;
url: string;
notes: string;
groupPath?: string;
otp?: string;
};
export type KeePassGroup = {
name: string;
path: string;
};
export type KeePassOpenOptions = {
password: string;
keyFile?: string;
};
export type KeePassFindQuery = {
title?: string;
username?: string;
url?: string;
groupPath?: string;
};
export type KeePassCommand =
| { command: "list-entries" }
| { command: "find-entries"; query: KeePassFindQuery }
| { command: "list-groups" };
export type KeePassResponse<T> =
| {
ok: true;
data: T;
}
| {
ok: false;
error: string;
};
+15
View File
@@ -0,0 +1,15 @@
# Tests
This directory is organized as follows:
- `tests/unit/`: fast unit tests with mocks
- `tests/integration/`: optional integration tests that may require `pykeepass` and a Python environment
- `tests/fixtures/`: bundled KeePass databases and matching JSON credentials/content files
## Conventions
- Use `*.test.ts` filenames.
- Keep unit tests isolated and fast.
- Prefer mocking the Python bridge in unit tests.
- Put environment-dependent checks in `tests/integration/`.
- Keep fixture-driven expectations aligned with the matching `*.kdbx.json` file.
- Integration tests should skip or self-report when prerequisites are missing.
BIN
View File
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
{
"password": "123",
"content": {
"root": {
"folders": [
{
"name": "Folder1",
"folders": [],
"entries": [
{
"title": "f1-item1",
"user": "f1-item1",
"password": "123"
}
]
},
{
"name": "Folder2",
"folders": [],
"entries": [
{
"title": "f2-item1",
"user": "f2-item1",
"password": "123"
}
]
}
],
"entries": [
{
"title": "root",
"user": "root",
"password": "123"
},
{
"title": "otp1",
"user": "otp1",
"password": "123",
"otp": "otpauth://totp/otp1:otp1?secret=234324AB34%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=otp1"
}
]
}
}
}
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
{
"password": "123"
}
+10
View File
@@ -0,0 +1,10 @@
# Integration Tests
This directory is reserved for tests that require external dependencies or a Python environment with `pykeepass` installed.
## Conventions
- Use `*.test.ts` filenames.
- Keep tests here isolated from unit tests.
- Prefer explicit setup/skip logic when runtime dependencies are missing.
- Integration tests should verify the real KeePass bridge when `pykeepass` and fixture credentials are available.
- Prefer fixtures in `tests/fixtures/` and keep expectations aligned with the companion JSON file.
+148
View File
@@ -0,0 +1,148 @@
import { expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { openKeePassDatabase } from "../../src/keepass";
type FixtureEntry = {
title: string;
user: string;
password: string;
otp?: string;
};
type FixtureFolder = {
name: string;
folders: FixtureFolder[];
entries: FixtureEntry[];
};
type FixtureData = {
password: string;
content: {
root: FixtureFolder;
};
};
const FIXTURE_PATH = "tests/fixtures/data.kdbx";
const FIXTURE_DATA_PATH = "tests/fixtures/data.kdbx.json";
async function ensurePyKeePass(): Promise<boolean> {
const child = Bun.spawn(["python3", "-c", "import pykeepass; print('ok')"], {
stdout: "pipe",
stderr: "pipe",
});
return child.exited.then((code) => code === 0);
}
function flattenEntries(folder: FixtureFolder, groupPath = ""): Array<FixtureEntry & { groupPath: string }> {
const ownEntries = folder.entries.map((entry) => ({ ...entry, groupPath }));
const nestedEntries = folder.folders.flatMap((child) => flattenEntries(child, groupPath ? `${groupPath}/${child.name}` : child.name));
return [...ownEntries, ...nestedEntries];
}
function groupTreeToString(folder: FixtureFolder, indent = ""): string[] {
const lines = [`${indent}${folder.name || "Racine"}/`];
for (const entry of folder.entries) {
lines.push(`${indent} - ${entry.title}`);
}
for (const child of folder.folders) {
lines.push(...groupTreeToString(child, `${indent} `));
}
return lines;
}
test("opens the bundled data fixture and exposes all entries and values", async () => {
const [{ password, content }, pykeepassReady] = await Promise.all([
readFile(FIXTURE_DATA_PATH, "utf8").then((raw) => JSON.parse(raw) as FixtureData),
ensurePyKeePass(),
]);
if (!pykeepassReady) {
console.log("Skipping integration test: pykeepass is not installed");
return;
}
const expectedEntries = flattenEntries(content.root);
const db = openKeePassDatabase(FIXTURE_PATH, { password });
const entries = await db.listEntries();
expect(entries).toHaveLength(expectedEntries.length);
expect(entries.map((entry) => entry.title).sort()).toEqual(expectedEntries.map((entry) => entry.title).sort());
for (const expected of expectedEntries) {
const actual = entries.find((entry) => entry.title === expected.title);
expect(actual).toBeTruthy();
expect(actual?.username).toBe(expected.user);
expect(actual?.password).toBe(expected.password);
expect(actual?.notes).toBe("");
expect(actual?.url).toBe("");
expect(actual?.groupPath).toBe(expected.groupPath);
if (expected.otp) {
expect(actual?.otp).toBe(expected.otp);
}
}
});
test("lists the groups from the bundled data fixture", async () => {
const [{ password }, pykeepassReady] = await Promise.all([
readFile(FIXTURE_DATA_PATH, "utf8").then((raw) => JSON.parse(raw) as FixtureData),
ensurePyKeePass(),
]);
if (!pykeepassReady) {
console.log("Skipping integration test: pykeepass is not installed");
return;
}
const db = openKeePassDatabase(FIXTURE_PATH, { password });
const groups = await db.listGroups();
expect(groups).toEqual([
{ name: "Racine", path: "" },
{ name: "Folder1", path: "Folder1" },
{ name: "Folder2", path: "Folder2" },
]);
});
test("finds entries in the bundled data fixture", async () => {
const [{ password }, pykeepassReady] = await Promise.all([
readFile(FIXTURE_DATA_PATH, "utf8").then((raw) => JSON.parse(raw) as FixtureData),
ensurePyKeePass(),
]);
if (!pykeepassReady) {
console.log("Skipping integration test: pykeepass is not installed");
return;
}
const db = openKeePassDatabase(FIXTURE_PATH, { password });
const entries = await db.findEntries({ title: "f1-item1" });
expect(entries).toHaveLength(1);
expect(entries[0]?.title).toBe("f1-item1");
expect(entries[0]?.username).toBe("f1-item1");
expect(entries[0]?.password).toBe("123");
expect(entries[0]?.groupPath).toBe("Folder1");
});
test("uses the JSON fixture content as the source of truth for expectations", async () => {
const { content } = JSON.parse(await readFile(FIXTURE_DATA_PATH, "utf8")) as FixtureData;
expect(content.root.folders).toHaveLength(2);
expect(content.root.entries).toHaveLength(2);
expect(flattenEntries(content.root)).toEqual([
{ title: "root", user: "root", password: "123", groupPath: "" },
{ title: "otp1", user: "otp1", password: "123", otp: "otpauth://totp/otp1:otp1?secret=234324AB34%3D%3D%3D%3D%3D%3D&period=30&digits=6&issuer=otp1", groupPath: "" },
{ title: "f1-item1", user: "f1-item1", password: "123", groupPath: "Folder1" },
{ title: "f2-item1", user: "f2-item1", password: "123", groupPath: "Folder2" },
]);
expect(groupTreeToString(content.root)).toEqual([
"Racine/",
"\t- root",
"\t- otp1",
"\tFolder1/",
"\t\t- f1-item1",
"\tFolder2/",
"\t\t- f2-item1",
]);
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, mock, test } from "bun:test";
import { KeePassDatabase } from "../../src/keepass";
const spawnMock = mock(() => {
throw new Error("spawn should be mocked per test");
});
mock.module("node:child_process", () => ({
spawn: spawnMock,
}));
describe("KeePassDatabase", () => {
test("listEntries parses successful bridge response", async () => {
spawnMock.mockImplementation(() => {
const listeners: Record<string, ((chunk: Buffer | string) => void)[]> = {};
const child = {
stdin: {
write: () => undefined,
end: () => {
listeners.close?.forEach((cb) => cb(Buffer.from("")));
},
},
stdout: {
on: (event: string, cb: (chunk: Buffer | string) => void) => {
listeners[event] ??= [];
listeners[event].push(cb);
if (event === "data") {
cb(JSON.stringify({ ok: true, data: [{ title: "Entry" }] }));
}
},
},
stderr: { on: () => undefined },
on: (event: string, cb: (code?: number | null) => void) => {
if (event === "close") queueMicrotask(() => cb(0));
},
};
return child as never;
});
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
const entries = await db.listEntries();
expect(entries).toEqual([{ title: "Entry" }]);
expect(spawnMock).toHaveBeenCalled();
});
test("throws on bridge error payload", async () => {
spawnMock.mockImplementation(() => {
const child = {
stdin: { write: () => undefined, end: () => undefined },
stdout: { on: (_event: string, cb: (chunk: Buffer | string) => void) => cb('{"ok":false,"error":"boom"}') },
stderr: { on: () => undefined },
on: (event: string, cb: (code?: number | null) => void) => {
if (event === "close") queueMicrotask(() => cb(1));
},
};
return child as never;
});
const db = new KeePassDatabase("db.kdbx", { password: "secret" }, "python3", new URL("file:///tmp/bridge.py"));
await expect(db.listEntries()).rejects.toThrow("boom");
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}