Independent, mountable, encrypted backup discs.
brb bin-packs a directory tree into disc-sized groups, builds one self-contained
SquashFS image per disc, encrypts it with age,
computes par2 recovery data over the ciphertext, and wraps each disc in an ISO
ready to burn to BD-R, BD-R DL or BDXL — including
M-DISC, which is what you want if the
discs are meant to outlive the decade.
bin-pack the tree into disc-sized groups
-> mksquashfs (one self-contained image per disc)
-> age (encrypt the image)
-> par2 (recovery data over the CIPHERTEXT)
-> xorriso (one ISO per disc)
-> BD-R / BD-R DL / BDXL
Every disc is independent. Losing disc 7 loses exactly the files on disc 7; every other disc still restores on its own. The last step of a restore is
mount -o loop,ro disc07.squashfs /mntwhich needs nothing but the Linux kernel — no brb, no Python, no bespoke
archive format.
brb ships as two implementations with a deliberate split:
| Go | brb-linux-amd64 / brb-linux-aarch64 |
writes and reads. Packing, mksquashfs, encryption, par2, ISOs, burning, resume — and every restore command too |
| bash | brb.sh |
reads only. doctor, ingest, restore, mount, list, index, verify-disc |
The Go build is the tool you use day to day. The bash script exists for one reason: a restore fifteen years from now, by someone holding a disc and no particular reason to trust an 8 MB binary. It is a bit over a thousand lines, comments and all, and can be read end to end in an afternoon, so the answer to "what is this going to do to my bytes" is available without running anything.
brb.sh refuses backup, plan, burn, iso and init-key by name, and says
where they went, rather than failing as an unknown command.
The two are held to the same on-disc format by xcompat-test.sh, which builds a
set with the Go build and reads it with both, and asserts they produce identical
answers — down to the encrypted index and the byte-for-byte restored tree.
The distinction that matters is not between the two programs but between writing discs and reading them. Writing needs the most tooling and you need it today. Reading is what someone needs in fifteen years, and it is deliberately tiny: age, par2, unsquashfs — and for the final step, nothing but the Linux kernel. Every member of that set is a small, specified, widely-implemented format that outlives whoever wrote this.
Run doctor on either program at any time; each checks the dependencies it
actually uses and reports what it found.
The Go build, which writes and reads:
| Binary | Package (Fedora) | Needed for |
|---|---|---|
mksquashfs |
squashfs-tools ≥ 4.5 |
building each disc image — backup only |
unsquashfs |
squashfs-tools |
extracting and listing an image — restore |
par2 |
par2cmdline |
parity over the ciphertext, and repair — both |
xorriso |
xorriso |
building and burning ISOs — backup and burn |
It does not need the age binary: encryption uses
filippo.io/age as a library, so the format is exactly
the same and one fewer thing has to be installed.
The bash reader (brb.sh), which restores:
| Binary | Package (Fedora) | Needed for |
|---|---|---|
age |
age |
decrypting an image |
par2 |
par2cmdline |
repairing a rotted disc |
unsquashfs |
squashfs-tools |
extracting and listing |
sha512sum, stat, cp, cut, sort, tr, dd, df, readlink |
coreutils |
hashing, accounting, and the destination symlink check |
gzip / gunzip |
gzip |
reading the encrypted index |
find, awk, sed |
findutils, gawk, sed |
locating and formatting |
bash ≥ 4.4 |
bash |
the script itself (uses mapfile -d) |
No python3 for either — the bin-packer that once needed it is Go code now.
And to build the Go program from source: Go 1.25 or newer. Nothing else needs it, and a restore never does.
Optional, but worth having:
| Binary | Package (Fedora) | Why |
|---|---|---|
ddrescue |
ddrescue |
salvages partially readable discs; cp stops at the first I/O error, ddrescue does not — this is what makes par2 usable on a scratched disc |
udisksctl |
udisks2 |
lets verify-disc and ingest mount the drive for you |
eject, findmnt |
eject, util-linux |
disc swapping during ingest |
pv |
pv |
progress on long pipes |
age-keygen is deliberately not on that list. brb init-key generates both the
primary and the rescue keypair with the age library it already links, so neither
implementation ever execs it.
Fedora / RHEL / CentOS
sudo dnf install age squashfs-tools par2cmdline xorriso findutils gawk coreutils gzipsudo dnf install ddrescue udisks2 pv eject util-linuxDebian / Ubuntu
sudo apt install age squashfs-tools par2 xorriso findutils gawk coreutils gzipsudo apt install gddrescue udisks2 pv eject util-linuxNote the package name differences from Fedora: par2cmdline is par2, and
ddrescue is gddrescue (the ddrescue package on Debian is a different,
unrelated program).
Arch
sudo pacman -S age squashfs-tools par2cmdline libisoburn gawk coreutils gzipsudo pacman -S ddrescue udisks2 pv util-linuxxorriso is provided by libisoburn on Arch.
- squashfs-tools must be 4.5 or newer.
brbfeedsmksquashfsa NUL-delimited file list via-cpiostyle0, which does not exist in 4.4. Fedora 36+, Debian 12+ and Ubuntu 22.04+ ship 4.5 or newer; Debian 11 and Ubuntu 20.04 ship 4.4 and will not work.brb doctortests for the flag directly rather than parsing a version string, so trust it over this table. ageis packaged on Fedora, Debian 12+, Ubuntu 22.04+ and Arch. If your distribution does not carry it, take a static binary from the releases page — it is a single file with no runtime dependencies.- Go 1.25+ only if you build the Go program from source. A prebuilt static binary from any disc needs nothing at all.
The Go build is the one to install — it is the only one that can write a set:
cd go && go build -o ~/.local/bin/brb ./cmd/brbOr take a prebuilt static binary from any disc (brb-linux-amd64 /
brb-linux-aarch64, matching uname -m) — they need no libc, no interpreter
and no shared libraries.
The bash reader is a single self-contained script with nothing to build:
install -Dm755 brb.sh ~/.local/bin/brb.shInstall it under a distinct name. Both name themselves after however they were
invoked, so installing both as brb would make their help text and every
suggested command ambiguous — and one of them cannot back up.
Every disc carries the tool itself, three ways: the bash script, a static Go binary for each of the two architectures worth caring about, and the complete source. A restorer in fifteen years needs none of it — the manual restore path is four standard commands — but having it costs about 10 MiB out of a 100 MiB reserve, so there is no reason not to.
./build-dist.shThat cross-compiles brb-linux-amd64 and brb-linux-aarch64 (static, CGO_ENABLED=0,
so no libc and no shared libraries), vendors every Go dependency, and packs
brb-src.tar.gz. It writes to a directory you name, deliberately outside the working
tree — build output does not belong in the source:
./build-dist.sh /path/to/distWith no argument it uses $BRB_DIST_OUT, falling back to ~/brb-dist.
Then point brb at the result, either way:
export BRB_DIST_DIR=/path/to/distln -sfn /path/to/dist ./distbrb also looks in /usr/local/share/brb and /usr/share/brb. If it finds nothing it
warns and burns discs carrying only a copy of the binary that is running (brb-linux-amd64
on x86-64) — a missing payload never fails a backup. brb doctor reports exactly what it
found.
Building the payload needs a Go toolchain (1.25+); nothing else in brb does, and a
restore never does.
The source tarball vendors its dependencies, so it rebuilds with no network:
tar xzf brb-src.tar.gz && cd brb-*/go && go build -mod=vendor ./cmd/brbEverything here is the Go build — it is the one that writes. brb.sh can run
the last two steps (ingest, restore) and nothing before them.
brb doctorbrb init-keyinit-key writes ~/.config/brb/identity.txt (mode 0400) and appends its public
key to ~/.config/brb/recipients.txt. Back that identity file up somewhere
that is not these discs — a password manager, and printed on paper. Losing it
means losing every backup, permanently and irrecoverably.
Optionally, give yourself a second way in:
brb init-key --rescue-keyThat adds a second recipient whose identity is kept encrypted under a passphrase,
so the set restores with either the key file or something you remember. See
Keys, and the rescue key for what it does and why it
is built the way it is. It can be run later, on an archive that already exists —
discs built from that point on carry both keys. It asks for the passphrase on
the terminal, so it refuses to run under --yes.
brb planplan scans and reports how many discs the archive needs, without building
anything. Use it to sanity-check before committing to a multi-hour run.
brb backupbrb burn allbrb verify-disc 1brb restore /tmp/testrestoreDo that last step at least once, before you trust the set with anything. A backup you have never restored is a hypothesis, not a backup.
Then wipe staging, which held plaintext:
rm -rf /var/tmp/brbWriting a set — the Go build only. brb.sh refuses these by name and points
here.
| Command | What it does |
|---|---|
brb init-key |
generate an age keypair and recipients file |
brb init-key --rescue-key |
additionally add a second recipient whose identity is passphrase-protected. It asks for the passphrase on the terminal, so it is refused under --yes |
brb plan |
scan and show the disc layout without building anything |
brb backup |
build, encrypt, protect and image every disc. --verify-roundtrip decrypts each image back and compares hashes before the plaintext is deleted — it needs a readable, unencrypted AGE_IDENTITY and refuses to start without one |
brb burn <n|n-m|n-|all> |
burn ISOs, confirming before each; builds each ISO if it is missing and removes it again after a successful burn |
brb iso <n|n-m|n-|all> |
build ISO images without burning, for burning elsewhere |
Reading a set — either implementation. Substitute brb.sh for brb below
and the behaviour is the same; that equivalence is what xcompat-test.sh exists
to prove. The few places they differ are called out where they come up: --only
repetition and --keep-images below, KEEP_IMAGES in a shared config file under
Configuration. One more worth knowing before you script
anything: brb.sh ingest prompts on /dev/tty between discs even with a mount
path and --yes, and fails outright with no terminal, where the Go build's
ingest runs unattended.
| Command | What it does |
|---|---|
brb doctor |
check dependencies and report which key a restore would use |
brb ingest [mount] |
copy discs back onto disk, prompting for each, any order |
brb restore <dest> [opts] |
repair, decrypt and extract, overwriting what is already in <dest> — see below |
brb mount <n> <mountpoint> |
decrypt one disc's image and mount it read-only |
brb list <n> |
list the contents of one disc's image |
brb index [pattern] |
which disc holds a given path |
brb verify-disc <n> [mount] |
read a burned disc back and check every hash |
brb help |
full help text |
Global flags, before the command: --yes / -y to skip confirmations and
-c CONFIG to point at an alternate config file, on both; --no-color on the
Go build only. Per-command flags go after the command, and are recognised only
where they are documented — brb --resume backup is a usage error, brb backup --resume is the resume.
restore takes --only <path-in-archive> to extract a single path, and
--disc <n> to restore only one disc. --only is repeatable on the Go build
and every path given is extracted; brb.sh takes one path and a second --only
silently replaces the first. The Go build additionally takes --keep-images,
which is KEEP_IMAGES=1 for one run.
restore runs unsquashfs -f for every image, because discs 2..N extract into
a tree disc 1 already populated. Into a directory that has contents of its own
that means existing files are replaced by the backup's versions, mode and
mtime included. Both implementations guard it the same way, and both guards run
for --only and --disc too:
- A
<dest>that is not empty is named, its first few entries listed, and the overwrite confirmed before anything is extracted.--yesanswers that confirmation, so a--yesrestore into a live directory overwrites it without asking. Answering no aborts with "restore into an empty directory and merge by hand". - A
<dest>that holds a symlink resolving to a directory — at any depth — is refused outright, becauseunsquashfs -ffollows such a link and writes the backup's files outside the destination.--yescannot wave this one through; remove the link, or restore somewhere empty. A symlink to a file is harmless and is left alone.
A twenty-disc set takes days to build. backup records its progress in
$STAGING/state.json after every completed disc, so an interruption — a reboot,
a full disk, a Ctrl-C — costs only the disc that was in flight:
brb backup --resume--resume is a flag of backup, so it goes after the command; brb --resume backup is rejected as an unknown global flag.
It picks up at the disc after the last complete one. The state file lists every
path already written to a disc, and the learned pack ratios every finished disc
measured, so the resumed run re-scans the source tree and then skips exactly
what is already on a disc — files added since the run started land on later
discs, files deleted since are simply absent, and the run warns when the tree's
measured size has changed. ARCHIVE_NAME and SOURCE_DIR must match the
interrupted run, or the resume stops rather than write two different trees into
one set. Without --resume, a backup that finds finished discs in staging
refuses to start rather than overwrite days of work.
By default (ISO_MODE=ondemand) no ISOs are built during backup. burn
images each disc at the moment it goes into the drive and deletes the ISO once
it is written, which keeps staging near the size of the compressed set instead
of roughly 2.2x it for the whole length of a burn campaign — an ISO is a full
second copy of its disc directory. Set KEEP_ISOS=1 to keep them, or
ISO_MODE=eager for the old behaviour of building every ISO before the first
burn. To materialise them as files without burning — to take to another machine,
or a different burner — use brb iso all.
Config lives at ~/.config/brb/config (override with BRB_CONFIG). It is a
plain list of assignments, and every setting can equally be given as an
environment variable.
Nearly everything here is a writer setting, read only by the Go build. How a
set was built — source tree, disc geometry, compression, pack ratio, par2
parameters, ISO mode — was decided when it was written and is recorded in
MANIFEST.txt on every disc. The bash reader ignores all of it and uses only
STAGING, AGE_IDENTITY, AGE_RECIPIENTS_FILE, BURNER and KEEP_IMAGES.
brb.sh sources the config file — it executes it as bash. Anything in it
runs with your privileges, not just KEY=value lines: a config containing rm -rf ~ deletes your home directory the moment any brb.sh command loads it. So
trust the file exactly as much as you trust brb.sh itself. Never point -c
or BRB_CONFIG at a file you did not write — least of all one carried on a
disc, which is data from wherever that disc has been. The script's own header and
brb.sh help say the same thing.
The Go build does not source anything: it parses the file, accepts only
KEY=value (and the two array settings), and reports anything else as a syntax
error naming the line. Pointing it at the same hostile file is an error message,
not an execution.
One sharp edge if you share a config between them. Because brb.sh sources
the file, an unknown key is simply a variable nobody reads. The Go build
validates, and rejects an unknown key by refusing to run at all. KEEP_IMAGES
is the case that bites today: it is a real bash setting, and putting it in a
shared config stops the Go build dead. Use its --keep-images flag instead, or
keep the readers on separate config files.
SOURCE_DIR=/home/you
STAGING=/var/tmp/brb
DISC_TYPE=bd25 # bd25 | bd50 | bdxl100 | bdxl128 (M-DISC uses the same value)
DISC_CAPACITY_BYTES= # override for unusual media
COMPRESSION=zstd # zstd | xz | gzip | lz4 | lzo | none
COMPRESSION_LEVEL=19 # zstd 1-22
BLOCK_SIZE=1M
PACK_RATIO=1.00 # expected compressed/raw; lower = fuller discs
PAR2_REDUNDANCY=10
BURNER=/dev/sr0
BURN_SPEED=4
LABEL_PREFIX=BACKUP
ISO_MODE=ondemand # ondemand | eager — see "ISOs" above
KEEP_ISOS=0 # 1 keeps each ISO after a successful burn
AGE_RECIPIENTS_FILE=~/.config/brb/recipients.txt
AGE_IDENTITY=~/.config/brb/identity.txt # restore only; see below
DIST_DIR= # copies of brb for every disc; empty = auto-locate
KEEP_IMAGES=0 # reader: 1 keeps each decrypted image
PRUNE_DIRS=( ".cache" ".local/share/Trash" "snap" ) # paths relative to SOURCE_DIR
EXCLUDE_MASKS=( "*.pyc" "core.[0-9]*" ) # filename patternsThat is the useful subset, not the whole set of keys. Since the Go build rejects
a key it does not know, take the authoritative list from brb help, which prints
every setting with the value actually in force.
Note the mask is core.[0-9]*, not core. A bare core matches every
directory named core/ as well as the dump files, and a matching directory is
pruned whole — its contents vanish from the backup without appearing even in the
disc's directory skeleton, so a restored tree shows no sign anything is missing.
Any Go, Drupal or kernel checkout would lose its core/. This was a real bug;
the default now matches the dumps glibc actually writes and leaves directories
alone. Directory-shaped exclusions belong in PRUNE_DIRS.
DIST_DIR is the disc payload described under Building the disc
payload. In the environment it is spelled
BRB_DIST_DIR; left empty, brb looks beside itself for a dist directory,
then in /usr/local/share/brb and /usr/share/brb. Setting it to a directory
that is not there is reported rather than ignored, and a payload that cannot be
found never fails a backup — the discs simply carry fewer copies of the tool.
AGE_IDENTITY is only read by the restore side (restore, mount, list,
index), by doctor's round-trip check, and by backup --verify-roundtrip. A
plain backup never touches it: it encrypts to public keys and needs no secret
at all. Left empty, brb looks next to the recipients file for
identity.txt, then identity.txt.age, then
rescue-identity.txt.age, and uses the first one it finds — so a machine whose
plaintext identity has been shredded, or lost, keeps working with no
configuration change. An age-encrypted identity works anywhere a plain one does;
brb asks for the passphrase once per command, not once per disc.
Setting PRUNE_DIRS or EXCLUDE_MASKS in the config replaces the built-in
defaults rather than adding to them. The defaults prune the usual regenerable
caches: .cache, Trash, Steam, .var/app, snap, .npm/_cacache,
.cargo/registry, .rustup/toolchains, .gradle/caches, .m2/repository,
go/pkg/mod, container and Docker storage, and Vagrant boxes.
DISC_TYPE |
Media | Raw capacity | Usable image budget |
|---|---|---|---|
bd25 |
BD-R single layer | 23.31 GiB | 20.49 GiB |
bd50 |
BD-R DL dual layer | 46.61 GiB | 41.07 GiB |
bdxl100 |
BDXL triple layer | 93.23 GiB | 82.22 GiB |
bdxl128 |
BDXL quad layer | 119.21 GiB | 105.16 GiB |
The image budget is what is left after reserving 2% for ISO 9660 overhead,
RESERVE_BYTES (100 MiB by default) for the plaintext files carried on every
disc, and room for the par2 recovery data. Budgets above assume the default
PAR2_REDUNDANCY=10; raising redundancy shrinks the budget proportionally.
If the point of this exercise is long-term storage, buy M-DISC BD-R.
Ordinary recordable Blu-ray writes to an organic dye layer, which is a consumable: it degrades with heat, humidity and light, and the disc's lifetime is the dye's lifetime. M-DISC records into an inorganic, heat-resistant layer instead — the bit pattern is physically etched rather than chemically stained, so there is no dye left to fade. That is the whole difference, and it is a real one.
It needs no special support from brb or from your drive. M-DISC BD-R is
ordinary BD-R as far as the format is concerned, so any Blu-ray writer burns it
and any Blu-ray reader reads it. (This is unlike M-DISC DVD, which needed an
"M-Ready" burner — a distinction that still confuses people shopping for media.)
Nothing in this tool changes: use the same DISC_TYPE as the equivalent
conventional disc.
DISC_TYPE |
M-DISC available? |
|---|---|
bd25 |
yes — M-DISC BD-R 25 GB |
bd50 |
yes — M-DISC BD-R DL 50 GB |
bdxl100 |
yes — M-DISC BDXL 100 GB |
bdxl128 |
no — quad-layer 128 GB is conventional BD-R XL only |
On the longevity numbers, be skeptical. You will see "1,000 years" quoted. That figure is an extrapolation from accelerated-aging tests, not an observation — nobody has had one of these for a thousand years, and independent testing has been less enthusiastic than the marketing. What is defensible is the physical argument: an inorganic recording layer has no organic dye to break down, and that removes the failure mode that kills ordinary BD-R sitting in a warm cupboard. Treat M-DISC as clearly better than conventional BD-R for archival use, and treat any specific century count as a number somebody wanted to sell you.
Two practical notes:
- Burn slower than you think.
BURN_SPEED=4is the default here and is a reasonable ceiling for M-DISC; 2x is a defensible choice for a set you intend to keep. Write speed is not where you want to economise on a disc you are buying for its lifetime. - It costs more per gigabyte. That is the trade. For a set you plan to re-cut every few years, conventional BD-R is fine; for the set you want to still read in thirty years, the media is the cheapest part of the exercise.
None of this changes the honest caveat that the on-disc README already makes to whoever finds these later: the realistic failure mode is drive availability, not disc decay. M-DISC buys you a disc that outlasts the drives, which makes "can I still source a Blu-ray reader" the question that actually decides whether the archive survives. Plan for that too — and test your restores.
Discs are packed by uncompressed size, so brb has to guess how well the
content will compress before it compresses it. PACK_RATIO is that guess,
expressed as compressed ÷ raw.
The default of 1.00 assumes no compression at all. That is always safe but
leaves discs partly empty whenever the content actually is compressible. If your
first run reports images compressing to, say, 0.62 of raw, set PACK_RATIO=0.65
and re-run for fuller discs.
You are not required to get this right. If an image overshoots its budget, brb
measures the real ratio, re-packs that disc with it, and continues on its own —
up to MAX_SHRINK_ATTEMPTS (4) times. A bad guess costs rebuild time, not
correctness.
You rarely have to tune it at all: PACK_RATIO_ADAPT=1 is the default, so every
finished disc feeds its measured ratio back and the next disc is planned from the
worst of the last PACK_RATIO_WINDOW (3) discs times PACK_RATIO_MARGIN (1.05).
The estimate moves in both directions, so a stretch of incompressible files
raises it again rather than packing the rest of the set to a ratio only the early
discs achieved. PACK_RATIO is then the starting guess for disc 1. Set
PACK_RATIO_ADAPT=0 to hold the configured value fixed for the whole set.
brb uses zstd for the disc images and gzip for the index and the source
tarball. That looks inconsistent. It is deliberate, and the deciding question is
not the compression ratio — it is who has to decompress it, and what they need
installed at the time.
| Artifact | Compressor | Size | Decompressed by |
|---|---|---|---|
discNN.squashfs |
zstd | hundreds of GB | the kernel, on mount -o loop |
index.tsv.gz.age |
gzip | KB to tens of MB | a person, with a userspace tool |
brb-src.tar.gz |
gzip | ~1–2 MB | a person, with tar xzf |
zstd costs nothing on the images, because SquashFS-zstd is decompressed by the
Linux kernel itself. The promise that mount -o loop,ro disc07.squashfs /mnt
needs no userspace tooling survives intact, and across hundreds of gigabytes the
ratio is worth real money in media.
The index inverts that calculus. It is the artifact you reach for when a disc has
been lost — you are asking "what was on it?", quite possibly from a rescue
USB or a borrowed machine. gunzip has been on every Unix since 1992 and is in
busybox and every base install; zstd dates from 2016 and is not guaranteed on a
minimal or elderly system. What you would gain is a rounding error: even at two
million files the index is perhaps 40 MB gzipped against 30 MB with zstd, on a
23 GiB disc. That is not a trade worth making in the one code path that runs when
things have already gone wrong. The source tarball follows the same reasoning.
So the rule is: zstd where the kernel decompresses it and the data is large, gzip where a person needs a userspace tool in a degraded situation and the data is small.
One tradeoff runs the other way, and it is worth knowing. SquashFS-zstd requires
kernel 4.14 or newer (2017). A restorer booting a genuinely ancient rescue kernel
would find mount fails on a zstd image where a gzip one would have worked; they
would fall back to unsquashfs, which still works but is no longer "nothing but
the kernel". If you value maximum mountability decades out over space, set
COMPRESSION=gzip; for maximum compression instead, COMPRESSION=xz. zstd is
the right default, not a free lunch.
README.md restore instructions, written for a stranger
MANIFEST.txt the whole set, and the exact tool versions used
SHA512SUMS hashes of every file on this disc
brb.sh the bash reader — restores this disc, and is short
enough to read before you trust it
brb-linux-amd64 static Go binary, 64-bit Intel/AMD
brb-linux-aarch64 static Go binary, 64-bit ARM
brb-src.tar.gz complete source for both, dependencies vendored
data/
discNN.squashfs.age the filesystem image, encrypted
discNN.squashfs.age.sha512 hash of the encrypted image
discNN.squashfs.sha512 hash of the image AFTER decryption
discNN.squashfs.age.par2 par2 index
discNN.squashfs.age.vol*.par2 recovery data
index.tsv.gz.age encrypted map of which disc holds which file
index.tsv.gz.age.sha512 hash of the encrypted index
sidecars.par2 par2 index over the small .sha512 files
sidecars.vol*.par2 recovery data for them
The .sha512 sidecars get their own parity because they are tiny and a single
rotted byte in one of them would otherwise condemn an image that is perfectly
intact. If a sidecar fails to verify but par2 says the image is whole, it is the
sidecar that is corrupt: par2 repair -- sidecars.par2 from the disc's data/
directory puts it back.
Every disc carries the full directory skeleton of the original tree — directories, symlinks, device nodes — so mounting any single disc shows you the whole shape of the backup, with the files that live on that disc present and the rest absent. Skeleton entries carry no data, so replicating them across discs is nearly free.
Parity is computed over the encrypted bytes, so it protects exactly what is physically on the disc.
This is the point of the design, so it is worth stating plainly. Given one disc and your age identity:
cp /mnt/data/disc07.squashfs.age* .The glob is deliberate: it brings the .sha512 sidecar and the .par2 files
along, which the next line needs.
sha512sum -c disc07.squashfs.age.sha512 || par2 repair -- disc07.squashfs.age.par2age -d -i /path/to/identity.txt -o disc07.squashfs disc07.squashfs.agesudo mount -o loop,ro disc07.squashfs /mntThat is the whole restore path. unsquashfs -d /dest disc07.squashfs extracts
instead of mounting; run it as root if you want original ownership back.
If a disc will not read cleanly, pull it off with ddrescue first — it fills
unreadable regions with zeros and keeps going, which is exactly what par2 needs:
ddrescue -d -r3 /mnt/data/disc07.squashfs.age ./disc07.squashfs.age ./disc07.mapfileBackup needs public keys only. Every line of the recipients file is an age recipient, every image is encrypted to all of them, and any single one of the matching identities restores the whole set on its own. No secret has to sit on the backup machine for the days or weeks a set takes to build, so compromising that machine yields nothing that outlives the compromise.
The price of that design is a single point of failure in the other direction:
lose identity.txt and the archive is gone. brb init-key --rescue-key is the
answer.
brb init-key --rescue-keyThis is the Go build. brb.sh has no init-key at all — it reads disc sets and
refuses every writing command by name — so a rescue key is minted with the Go
binary (or by hand, below) and read by both.
It mints a second keypair and appends its public key to the recipients file, so every disc from then on is encrypted to both. Discs already burned are not, and still need the key they were built with. What makes it a rescue key is how the private half is stored — the shape is this:
age-keygen | age -p -o ~/.config/brb/rescue-identity.txt.age
The identity goes from age-keygen's stdout straight into age -p's stdin,
never through a file. brb does the same thing in-process with the age library
it already links, so it needs no age-keygen binary and the plaintext identity
is never written anywhere: it is generated in memory and handed straight to the
scrypt container. That matters because there would be nothing useful to shred
afterwards — shred cannot promise anything on a copy-on-write, compressed or
flash-translated filesystem, and the plaintext would be in the page cache
regardless. What lands on disk is a ~400-byte file, mode 0400, encrypted under a
passphrase you chose and typed twice. Copy it to a USB stick, a cloud drive, a
relative's machine: it is inert without the passphrase.
The path is not configurable: rescue-identity.txt.age, beside the recipients
file, is the last place both readers look for an identity. An existing one is
never overwritten — move it aside first — and an existing identity.txt is left
untouched, which is what makes --rescue-key safe to run on an archive that
already exists.
Restoring with it needs no special flags, on either implementation. Once
identity.txt is gone, brb finds rescue-identity.txt.age, asks for the
passphrase once, and carries on:
brb restore /path/to/destination
# or, explicitly:
AGE_IDENTITY=/media/usb/rescue-identity.txt.age brb restore /path/to/destinationDo not add --yes to that: the Go build refuses to unlock an encrypted identity
under it, since a run that promised to be unattended has nobody to type a
passphrase. Run it without, and answer the one prompt.
Without brb, age takes the encrypted identity wherever a plain one goes:
age -d -i rescue-identity.txt.age -o disc07.squashfs disc07.squashfs.ageThe obvious request is "let me use a passphrase instead of a key file". brb
deliberately does not do that, for three reasons:
- age will not express "my key OR my passphrase" in one file. A passphrase in age is an scrypt stanza, and age refuses to mix an scrypt stanza with recipient stanzas. Encrypting with a passphrase therefore means giving up recipients entirely — no multi-key sets, no rescue key, no third party who can restore for your estate.
- A stolen disc is ciphertext forever. Whoever holds the disc holds the attack surface, on their hardware, with unlimited time. A passphrase a human can memorise loses that race over the decades this medium is meant to last; an X25519 key does not. Passphrase-encrypting the images would move the secret onto the thing you lose control of.
- Backup would need a secret on the machine. Today it needs only public keys. Passphrase encryption would mean the passphrase — and so the ability to decrypt every disc — is present on the backup host for the whole run.
The rescue key inverts all three. The discs stay encrypted to public keys, and the passphrase guards one small file the thief does not have. Guessing at it requires stealing that file first, and if they have stolen it, you can generate a new key and rebuild — which you cannot do with discs already in the wild.
The corollary: never put the rescue file on these discs. A file stored next to its own passphrase, or on the media it unlocks, is one secret, not two.
brb doctor reports whether a rescue key is present, and which identity a
restore on this machine would use.
- Staging holds unencrypted images while a backup runs. Each image is deleted
as soon as it is encrypted, but at any moment one full disc's worth of your
plaintext is sitting in
$STAGING. Put staging on an encrypted volume, or wipe it afterwards.brbwarns about this and asks for confirmation before starting. $STAGING/restoreholds decrypted images — plaintext, mode 0700.restoredeletes each one as soon as its contents are on disk, so by default nothing is left there but what a failed run dropped.KEEP_IMAGES=1(or--keep-images) keeps them all, andlistandmountleave the image they decrypted behind by design. Remove them when you are done:rm -rf $STAGING/restore.- The recipients file contains public keys only and is harmless. The identity file is the secret, and it is never written to a disc.
- Encrypting to multiple recipients is supported: append more
age1...public keys to the recipients file and every image becomes decryptable by any of them.brb init-key --rescue-keyis that mechanism applied to your own second key — see Keys, and the rescue key. - An identity can be passphrase-protected (
age -p) and used exactly like a plain one on the restore side;brbunlocks it once per command. The passphrase is read from/dev/ttyand nowhere else, so it is the one prompt--yescannot answer: with no terminal,brbsays so and stops rather than hanging, and an empty passphrase is reported as an empty passphrase rather than as a missing terminal. The Go build refuses--yesoutright when the only identity it can find is encrypted — a run that promised to be unattended should not stop to ask — and tells you to run without it;brb.shprompts anyway. An unattendedbackupis unaffected either way — it encrypts to public keys and reads no identity at all. The exception isbackup --verify-roundtrip, which decrypts every image back to prove the set readable: that needs an unencryptedAGE_IDENTITYand refuses to start without one, rather than stopping for a passphrase hours into a run. - Run
brb backupas root if you want to capture files you cannot read as your own user, and to record real ownership. As a non-root user, unreadable files are skipped and ownership is recorded as yours.
Known and deliberate, but you should hear them before you rely on this:
- Not incremental. Every run is a full backup of the whole tree. A completed set cannot be updated in place; the next backup builds a new one.
- Resuming is deliberate, not automatic. An interrupted run continues with
brb backup --resume, but a plainbackuprefuses to start on top of one rather than silently throwing the work away. The resumed run must agree with the interrupted one onARCHIVE_NAMEandSOURCE_DIR, and needs the state file in$STAGING; if staging has been cleared, the set is built again from scratch. A resume re-scans the source and skips what is already on a disc, so files added since the run started are picked up on later discs and a set can span two points in time — it warns when the tree's measured size has changed. - A single file larger than one disc cannot be stored.
brbdetects this duringplan/backupand stops rather than silently dropping it. Exclude it, use larger media, or split it yourself. - A restore overwrites its destination.
unsquashfs -freplaces existing files with the backup's versions, mode and mtime included, and--yesanswers the confirmation that would otherwise have stopped it. A destination holding a symlink to a directory is refused outright,--yesor not. See restore overwrites the destination. - A restore needs room for the extracted tree plus one decrypted image. Each
image is removed as soon as its contents are on disk, so only one exists at a
time.
KEEP_IMAGES=1(or--keep-imageson the Go build) keeps them all for repeated restores, and then you do need room for the whole archive twice. - ISOs are ISO 9660 level 3 only, not UDF. Level 3 multi-extent is what allows the >4 GiB images. Any Linux system reads these; some appliances that expect UDF on Blu-ray may not.
COMPRESSION_LEVELonly applies tozstdandgzip. It is silently ignored forxz,lz4andlzo, which mksquashfs tunes through different flags. Those compressors run at their own defaults.
Two suites, with no overlap. Both build real disc sets with the real tools — mksquashfs, age, par2, xorriso — and restore them; neither mocks anything.
./go-e2e-test.shThe writer. Backs up a multi-disc set, kills it mid-set with kill -9 on the
process group, resumes it, and asserts the discs finished before the kill come
back byte-identical and the completed set restores byte-identical to its source.
Also covers ISO modes and that a finished run leaves no resume state behind.
./xcompat-test.shThe format contract. Builds a set with the Go build and reads it with both
implementations, asserting they agree — the restored trees, the encrypted index,
the disc inventory, list, --only, KEEP_IMAGES. It also runs the recipe
printed on the disc itself, with neither implementation involved, and damages a
set on purpose: a rotted .sha512 sidecar must not condemn an image par2 proves
is whole, while an image par2 cannot repair must still be refused rather than
decrypted.
Where the two genuinely differ, the check is written the way it ought to pass
and marked XFAIL with the divergence named. An XFAIL that starts passing is
reported as XPASS and counted as a failure, so a fixed divergence gets
promoted to a real assertion instead of sitting in the ledger forever. Nothing
known-broken is quietly omitted.
Plus the Go unit tests:
cd go && go test ./...MIT — see LICENSE. The full notice is also embedded in the header of
brb.sh itself, so the copy of the script carried on every disc stays properly
licensed even when separated from this repository.
No warranty. Test your restores.