LibreLedger is a single-person money ledger. The browser encrypts
everything (PBKDF2-HMAC-SHA256 with 250k iterations, then AES-256-GCM),
and a small standard-library Python server stores only the resulting
{v, salt, iv, ct} blob. Ledger files from the earlier Money Ledger version
open unchanged.
- On plain http to a LAN or VPN address, where browsers hide Web Crypto,
the app switches to vendored @noble/hashes and @noble/ciphers 2.2.0 and
says so on the lock screen. tests/crypto-interop.test.mjs shows both
paths read each other's files.
- The server hands out only the app's own files, sends a self-only CSP,
nosniff, frame denial and no-referrer, checks the shape of each blob,
refuses cross-site writes and writes atomically. It keeps the last 10
backups plus one per day for 30 days.
- The container runs that server from a digest-pinned python alpine
image, as an unprivileged user, with its data in /data and a health
check on /api/health.
Assisted-by: Claude Opus 5 <noreply@anthropic.com>
31 lines
1.4 KiB
JavaScript
31 lines
1.4 KiB
JavaScript
// LibreLedger — pure-JS crypto for pages without Web Crypto.
|
|
//
|
|
// Browsers only expose crypto.subtle in a secure context (HTTPS or localhost).
|
|
// Opened as plain http://<LAN or VPN address>:<port>, the app loads this module
|
|
// instead. It does exactly what the Web Crypto path does, so the two read and
|
|
// write the same ledger files:
|
|
// key = PBKDF2-HMAC-SHA256(UTF-8 passphrase, 16-byte salt, iterations) -> 32 bytes
|
|
// ct = AES-256-GCM(key, 12-byte iv, plaintext) -> ciphertext || 16-byte tag
|
|
// The primitives are unmodified copies of @noble/hashes and @noble/ciphers
|
|
// (audited, zero-dependency); versions and hashes are in vendor/README.md.
|
|
//
|
|
// The one real difference: Web Crypto keys are non-extractable, while here the
|
|
// 32 key bytes live in page memory until the ledger is locked. Use HTTPS where
|
|
// you can.
|
|
import { pbkdf2Async } from "./vendor/noble-hashes-2.2.0/pbkdf2.js";
|
|
import { sha256 } from "./vendor/noble-hashes-2.2.0/sha2.js";
|
|
import { gcm } from "./vendor/noble-ciphers-2.2.0/aes.js";
|
|
|
|
export async function deriveKeyBytes(passphraseBytes, salt, iterations) {
|
|
return pbkdf2Async(sha256, passphraseBytes, salt, { c: iterations, dkLen: 32, asyncTick: 25 });
|
|
}
|
|
|
|
export function encrypt(keyBytes, iv, plaintext) {
|
|
return gcm(keyBytes, iv).encrypt(plaintext);
|
|
}
|
|
|
|
// Throws on a wrong key or tampered data, like crypto.subtle.decrypt does.
|
|
export function decrypt(keyBytes, iv, ciphertext) {
|
|
return gcm(keyBytes, iv).decrypt(ciphertext);
|
|
}
|