Files
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

135 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* PBKDF (RFC 2898). Can be used to create a key from password and salt.
* @module
*/
import { hmac } from "./hmac.js";
// prettier-ignore
import { ahash, anumber, asyncLoop, checkOpts, clean, createView, kdfInputToBytes } from "./utils.js";
// Common start and end for sync/async functions
function pbkdf2Init(hash, _password, _salt, _opts) {
ahash(hash);
const opts = checkOpts({ dkLen: 32, asyncTick: 10 }, _opts);
const { c, dkLen, asyncTick } = opts;
anumber(c, 'c');
anumber(dkLen, 'dkLen');
anumber(asyncTick, 'asyncTick');
if (c < 1)
throw new Error('iterations (c) must be >= 1');
// RFC 8018 §5.2 defines `dkLen` as "a positive integer".
if (dkLen < 1)
throw new Error('"dkLen" must be >= 1');
// RFC 8018 §5.2 step 1 requires rejecting oversize `dkLen`
// before allocating the destination buffer.
if (dkLen > (2 ** 32 - 1) * hash.outputLen)
throw new Error('derived key too long');
const password = kdfInputToBytes(_password, 'password');
const salt = kdfInputToBytes(_salt, 'salt');
// DK = PBKDF2(PRF, Password, Salt, c, dkLen);
const DK = new Uint8Array(dkLen);
// U1 = PRF(Password, Salt + INT_32_BE(i))
const PRF = hmac.create(hash, password);
// Cache PRF(P, S || ...) prefix state so each block only appends INT_32_BE(i).
const PRFSalt = PRF._cloneInto().update(salt);
return { c, dkLen, asyncTick, DK, PRF, PRFSalt };
}
function pbkdf2Output(PRF, PRFSalt, DK, prfW, u) {
// Shared sync/async cleanup point: wipe transient PRF state
// while preserving the derived key buffer.
PRF.destroy();
PRFSalt.destroy();
if (prfW)
prfW.destroy();
clean(u);
return DK;
}
/**
* PBKDF2-HMAC: RFC 8018 key derivation function.
* @param hash - hash function that would be used e.g. sha256
* @param password - password from which a derived key is generated;
* JS string inputs are UTF-8 encoded first
* @param salt - cryptographic salt; JS string inputs are UTF-8 encoded first
* @param opts - PBKDF2 work factor and output settings. `dkLen`, if provided,
* must be `>= 1` per RFC 8018 §5.2. See {@link Pbkdf2Opt}.
* @returns Derived key bytes.
* @throws If the PBKDF2 iteration count or derived-key settings are invalid. {@link Error}
* @example
* PBKDF2-HMAC: RFC 2898 key derivation function.
* ```ts
* import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
* import { sha256 } from '@noble/hashes/sha2.js';
* const key = pbkdf2(sha256, 'password', 'salt', { dkLen: 32, c: Math.pow(2, 18) });
* ```
*/
export function pbkdf2(hash, password, salt, opts) {
const { c, dkLen, DK, PRF, PRFSalt } = pbkdf2Init(hash, password, salt, opts);
let prfW; // Working copy
const arr = new Uint8Array(4);
const view = createView(arr);
const u = new Uint8Array(PRF.outputLen);
// DK = T1 + T2 + ⋯ + Tdklen/hlen
for (let ti = 1, pos = 0; pos < dkLen; ti++, pos += PRF.outputLen) {
// Ti = F(Password, Salt, c, i)
// The last Ti view can be shorter than hLen, which applies
// RFC 8018 §5.2 step 4's T_l<0..r-1> truncation without extra copies.
const Ti = DK.subarray(pos, pos + PRF.outputLen);
view.setInt32(0, ti, false);
// F(Password, Salt, c, i) = U1 ^ U2 ^ ⋯ ^ Uc
// U1 = PRF(Password, Salt + INT_32_BE(i))
(prfW = PRFSalt._cloneInto(prfW)).update(arr).digestInto(u);
Ti.set(u.subarray(0, Ti.length));
for (let ui = 1; ui < c; ui++) {
// Uc = PRF(Password, Uc1)
PRF._cloneInto(prfW).update(u).digestInto(u);
for (let i = 0; i < Ti.length; i++)
Ti[i] ^= u[i];
}
}
return pbkdf2Output(PRF, PRFSalt, DK, prfW, u);
}
/**
* PBKDF2-HMAC: RFC 8018 key derivation function. Async version.
* @param hash - hash function that would be used e.g. sha256
* @param password - password from which a derived key is generated;
* JS string inputs are UTF-8 encoded first
* @param salt - cryptographic salt; JS string inputs are UTF-8 encoded first
* @param opts - PBKDF2 work factor and output settings. `dkLen`, if provided,
* must be `>= 1` per RFC 8018 §5.2. `asyncTick` is only a local
* scheduler-yield knob for this JS wrapper, not part of RFC 8018.
* See {@link Pbkdf2Opt}.
* @returns Promise resolving to derived key bytes.
* @throws If the PBKDF2 iteration count or derived-key settings are invalid. {@link Error}
* @example
* PBKDF2-HMAC: RFC 2898 key derivation function.
* ```ts
* import { pbkdf2Async } from '@noble/hashes/pbkdf2.js';
* import { sha256 } from '@noble/hashes/sha2.js';
* const key = await pbkdf2Async(sha256, 'password', 'salt', { dkLen: 32, c: 500_000 });
* ```
*/
export async function pbkdf2Async(hash, password, salt, opts) {
const { c, dkLen, asyncTick, DK, PRF, PRFSalt } = pbkdf2Init(hash, password, salt, opts);
let prfW; // Working copy
const arr = new Uint8Array(4);
const view = createView(arr);
const u = new Uint8Array(PRF.outputLen);
// DK = T1 + T2 + ⋯ + Tdklen/hlen
for (let ti = 1, pos = 0; pos < dkLen; ti++, pos += PRF.outputLen) {
// Ti = F(Password, Salt, c, i)
// The last Ti view can be shorter than hLen, which applies
// RFC 8018 §5.2 step 4's T_l<0..r-1> truncation without extra copies.
const Ti = DK.subarray(pos, pos + PRF.outputLen);
view.setInt32(0, ti, false);
// F(Password, Salt, c, i) = U1 ^ U2 ^ ⋯ ^ Uc
// U1 = PRF(Password, Salt + INT_32_BE(i))
(prfW = PRFSalt._cloneInto(prfW)).update(arr).digestInto(u);
Ti.set(u.subarray(0, Ti.length));
await asyncLoop(c - 1, asyncTick, () => {
// Uc = PRF(Password, Uc1)
PRF._cloneInto(prfW).update(u).digestInto(u);
for (let i = 0; i < Ti.length; i++)
Ti[i] ^= u[i];
});
}
return pbkdf2Output(PRF, PRFSalt, DK, prfW, u);
}
//# sourceMappingURL=pbkdf2.js.map