backup-server stores opaque encrypted data for BIP340 public keys. It cannot
decrypt or interpret anything it stores.
It serves two independent resources.
Wallet backups are one authenticated head per public key, replaced in place:
POST /api/v1/wallet-backups/fetchPUT /api/v1/wallet-backupsDELETE /api/v1/wallet-backups
Private descriptor records are immutable publications addressed by their ciphertext hash within a publisher namespace, retrieved by opaque lookup tokens the client derives and the server never interprets:
POST /api/v1/descriptor-backupsPOST /api/v1/descriptor-backups/lookup
The descriptor resource is additive. It changes no wallet backup request format, response, or semantic.
See CHANGELOG.md for the v0.4.0 release scope. The package
version is independent of the API version; both resources use /api/v1/.
cargo build --release --lockedTLS and public routing belong to the reverse proxy. The application binds to loopback and has no outbound network client or administrative API.
export BACKUP_SERVER_DB_PATH=/var/lib/backup-server/backup.sqlite3
export BACKUP_SERVER_MAX_LIVE_BYTES=<bytes>
export BACKUP_SERVER_MAX_HEADS=<count>
export BACKUP_SERVER_LIMITER_MAX_SUBJECTS=<count>
backup-server serveFour variables are required: BACKUP_SERVER_DB_PATH,
BACKUP_SERVER_MAX_LIVE_BYTES, BACKUP_SERVER_MAX_HEADS, and
BACKUP_SERVER_LIMITER_MAX_SUBJECTS. The optional variables — object size
ceilings, rate windows, admission budgets, concurrency, timeouts, descriptor
record and lookup bounds, and log level — are enumerated with their
development defaults in src/config.rs. Descriptor variables all carry
DESCRIPTOR in their name and every one of them is optional. Size the shared
queue and growth budget for both resources: the queue must exceed the sum of
all in-flight limits, and the growth bucket must admit the largest accepted
metadata or descriptor ciphertext. Defaults do not make every earlier custom
configuration valid; check the intended configuration before starting service.
Production limits are set in the deployment environment and are not
published. The Nginx files under deploy/ are structural templates whose
rates are likewise tuned privately before deployment.
Unknown BACKUP_SERVER_* variables stop startup. Contradictory combinations
stop startup. The reverse proxy must replace X-Real-IP with exactly one
validated source address; missing or malformed source identity is rejected.
All policy changes require a process restart; there is no runtime reload. Persistent admission balances survive that restart and are clamped when capacity is lowered. The per-npub rolling windows restart empty. Per-source rate limiting happens in Nginx; the application validates the proxy-supplied source header and fails closed without it.
New heads draw from a persistent head-admission bucket, and every positive byte delta — new heads and tombstone revivals included — draws from a persistent growth bucket. Deletes never refund these budgets. Admission checks and mutations commit in one SQLite transaction, so concurrent requests cannot overshoot a bucket.
Descriptor records draw from the same persistent growth bucket, but not from the head bucket. Exhausting shared growth capacity can temporarily prevent metadata creation or growth as well as descriptor publication. Descriptor counts are additionally bounded per publisher and across the service.
Each descriptor lookup page consumes one lookup-window request. Size the
window for multi-page recovery and ordinary retries. Clients must honor
Retry-After and retain a continuation cursor when interrupted; increasing
limits is not a substitute for resumable recovery. The per-token-set window
does not replace the proxy's per-source and global limits.
The database carries a schema version. A version 1 database is upgraded to
version 2 on startup by adding the two descriptor tables and their index; the
upgrade never reads or writes wallet_backup_heads, and a fresh database is
built by running the same migration. An aggregate verification digest is
unchanged by the upgrade alone, so an operator comparing before and after
sees drift only when descriptor records actually exist.
Copying the database is an operations task, done with standard SQLite tooling while the server runs:
sqlite3 /var/lib/backup-server/backup.sqlite3 \
".backup /srv/backup-server/backup-2026-08-26.sqlite3"
backup-server verify-backup /srv/backup-server/backup-2026-08-26.sqlite3verify-backup checks what generic tooling cannot: schema shape, admission
rows, head and byte consistency, and descriptor lookup associations. Each
descriptor must have 1–16 valid associations; no association may point at a
missing record. The aggregate digest includes descriptor contents, cursor
identities and sorted lookup associations for before-and-after comparison.
Changing a well-formed token changes that digest, but only comparison with a
trusted earlier digest can identify the change; the server cannot determine
which token belongs to an encrypted descriptor. Always verify the copy, never
the live file in its place, and do not replace the previous backup until the
new copy verifies. Restoration is an offline operation: stop the service,
restore the verified file, and restart.
cargo fmt --all -- --check
cargo check --all-targets --locked
cargo clippy --all-targets --locked -- -D warnings
cargo test --all-targets --locked
cargo build --release --locked
cargo doc --no-deps --document-private-items --locked
cargo auditSee docs/protocol-v1.md and docs/descriptor-v1.md for the wire contracts, and SECURITY.md for the security boundary.