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>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
# LibreLedger
|
||||
|
||||
An encrypted personal money ledger: **your data is encrypted in your browser;
|
||||
the server never sees it.**
|
||||
|
||||
One screen, one continuous running balance:
|
||||
|
||||
- **Balances** at the top: your accounts and their balances, with a grand total. That total is where the ledger *opens*.
|
||||
- **Monthly ledger** below: one block per month, a table of `Date · Type · Description · In · Out · Balance`. The balance runs on from your account balances and **carries from each month into the next**.
|
||||
- **Type** comes from the row: money *in* shows a green **Money** badge, money *out* an amber **Cost** badge.
|
||||
- Each row has a **category** (emoji and colour) for tagging entries at a glance.
|
||||
- Insert or remove rows anywhere, and add or remove whole months.
|
||||
- **Recurring** bills and income (rent, phone, salary…), each with an amount, a category and a schedule:
|
||||
- **Monthly** on a chosen day, **every N weeks** from an anchor date, or **yearly** on a month and day.
|
||||
- **🔁 Add recurring** drops every matching occurrence into a month as real, editable rows; **+ Add month** moves to the next calendar month and pre-fills it.
|
||||
- **Totals**, **Savings** and **Housing affordability** summaries, several **budgets** in one ledger, CSV and PDF export.
|
||||
- **Dark / light theme**, dark by default.
|
||||
|
||||
## Zero-knowledge encryption
|
||||
|
||||
- Everything is encrypted **in the browser** with **AES-256-GCM**. The key is
|
||||
derived from your passphrase with **PBKDF2-HMAC-SHA256, 250,000 iterations**
|
||||
and a random 16-byte salt; each save uses a fresh random 12-byte IV.
|
||||
- The server (`server.py`) only stores and returns the resulting blob,
|
||||
`{ "v": 1, "salt": …, "iv": …, "ct": … }` (base64). It never receives the
|
||||
passphrase, the key or any figure from the ledger.
|
||||
- There is no account and no recovery. **Lose the passphrase and the data is
|
||||
gone**; that is the point.
|
||||
- The page loads nothing from anywhere else. The server sends a strict
|
||||
Content-Security-Policy (`'self'` only), `nosniff`, frame denial and
|
||||
`no-referrer`, and serves only the app's own files.
|
||||
|
||||
### Plain http on your LAN or VPN
|
||||
|
||||
Browsers only give pages their built-in encryption (Web Crypto) in a *secure
|
||||
context*: HTTPS or `http://localhost`. Opened as `http://192.168.x.x:port` or
|
||||
over a VPN address, LibreLedger switches to its **built-in fallback**: the same
|
||||
PBKDF2-SHA256 and AES-256-GCM from the audited
|
||||
[@noble/hashes](https://github.com/paulmillr/noble-hashes) and
|
||||
[@noble/ciphers](https://github.com/paulmillr/noble-ciphers) libraries,
|
||||
vendored and pinned in [`vendor/`](vendor/README.md). The files it writes are
|
||||
identical in format, so a ledger saved one way opens the other way.
|
||||
|
||||
The lock screen says when the fallback is in use. Unlocking takes a few seconds
|
||||
longer, and the key sits in page memory rather than in the browser's key store,
|
||||
so **prefer HTTPS** whenever you have it. Plain http also means anyone on the
|
||||
network path can see (encrypted) traffic and could tamper with the page itself,
|
||||
so only use it on a network you trust.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker run -d --name libreledger -p 127.0.0.1:8080:8080 \
|
||||
-v libreledger-data:/data \
|
||||
git.libreportal.org/libreportal/libreledger:1.0.0
|
||||
# open http://localhost:8080
|
||||
```
|
||||
|
||||
Or build it yourself: `docker build -t libreledger .`
|
||||
|
||||
The image runs `server.py` on port 8080 as an unprivileged user, keeps its data
|
||||
in the `/data` volume and has a health check on `/api/health`. Put HTTPS in
|
||||
front of it (Caddy, Traefik, nginx…) when it is reachable from anywhere but
|
||||
your own machine.
|
||||
|
||||
### Without Docker
|
||||
|
||||
```bash
|
||||
python3 server.py # http://127.0.0.1:8080, data in ./data
|
||||
python3 server.py --host 0.0.0.0 --port 8080 --data-dir /srv/libreledger
|
||||
```
|
||||
|
||||
Python 3.9 or newer, standard library only.
|
||||
|
||||
| Option | Environment | Default |
|
||||
| --- | --- | --- |
|
||||
| `--host` | `LIBRELEDGER_HOST` | `127.0.0.1` |
|
||||
| `--port` | `LIBRELEDGER_PORT` | `8080` |
|
||||
| `--data-dir` | `LIBRELEDGER_DATA_DIR` | `./data` |
|
||||
| `--backup-interval` | `LIBRELEDGER_BACKUP_INTERVAL` | `600` seconds (`0`: every save) |
|
||||
| `--backup-keep-recent` | `LIBRELEDGER_BACKUP_KEEP_RECENT` | `10` |
|
||||
| `--backup-keep-daily` | `LIBRELEDGER_BACKUP_KEEP_DAILY` | `30` |
|
||||
|
||||
### Your data
|
||||
|
||||
- `<data-dir>/ledger.enc` is the ledger, written atomically on every change.
|
||||
- `<data-dir>/backups/ledger-<UTC time>.enc` are earlier versions: the newest
|
||||
10, plus the last one of each of the most recent 30 days. At most one is taken
|
||||
per backup interval.
|
||||
- All of them are encrypted with the passphrase that was current when they were
|
||||
written. To restore one, stop the server and copy it over `ledger.enc`, or
|
||||
use **Restore** in the app.
|
||||
- To move a ledger to another server, copy `ledger.enc` into its data folder
|
||||
and unlock it with your passphrase. Files from the earlier "Money Ledger"
|
||||
version of this app work as they are.
|
||||
|
||||
The server has no login of its own: anyone who can reach it can fetch the
|
||||
encrypted file or overwrite it (the backups cover the latter). Keep it on a
|
||||
private network, a VPN, or behind an authenticating proxy.
|
||||
|
||||
### Install on LibrePortal
|
||||
|
||||
LibreLedger is in the [LibrePortal](https://git.libreportal.org/LibrePortal/LibrePortal)
|
||||
app catalog: `libreportal app install libreledger`, or **Apps → LibreLedger**
|
||||
in the WebUI. It installs *private* (reachable only from your trusted places),
|
||||
with its data included in LibrePortal's backups, and on
|
||||
`https://ledger.<your domain>` when a domain is set up.
|
||||
|
||||
## In the app
|
||||
|
||||
- **💾 Saved to disk**: the encrypted ledger is saving to the server.
|
||||
- **🔗 Link file** *(Chrome/Edge desktop, when opened without the server)*:
|
||||
auto-save to an encrypted file you choose.
|
||||
- **Export CSV / PDF**: plaintext exports of the decrypted data. Handle them with care.
|
||||
- **Backup / Restore**: download or load an *encrypted* copy.
|
||||
- **Change passphrase**: re-encrypts with a new passphrase. Older backups keep the old one.
|
||||
- **Lock**: forgets the key and the data; the passphrase is needed again.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
python3 tests/test_server.py # server: allowlist, headers, checks, backups
|
||||
node tests/crypto-interop.test.mjs # Web Crypto <-> fallback interop (Node 22.12+)
|
||||
```
|
||||
|
||||
Files: `index.html` (markup and lock screen), `styles.css`, `app.js` (state,
|
||||
rendering and the crypto layer), `theme.js`, `crypto-fallback.js`, `server.py`,
|
||||
`banks/` (logos, see `banks/NOTICE.txt`), `vendor/`.
|
||||
|
||||
## Licence
|
||||
|
||||
[GNU AGPLv3](LICENSE). The vendored noble libraries are MIT-licensed (their
|
||||
`LICENSE` files are next to them); bank logos are trademarks of their owners
|
||||
(`banks/NOTICE.txt`).
|
||||
Reference in New Issue
Block a user