Files
LibreLedger/crypto-fallback.js
T
LibrePortal 2cffc3ab07 Start LibreLedger: an encrypted money ledger that stores only ciphertext
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>
2026-09-17 01:17:16 +01:00

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);
}