Email security@radicle.tools, or open a private security advisory on GitHub. Please do not open a public issue for anything that would let someone else read a key.
Expect an acknowledgement within 72 hours and an assessment within a week. If a fix is warranted it ships as a patch release with an advisory naming you, unless you would rather not be named.
This program holds an ed25519 key that cannot be rotated, revoked or reissued. Everything that touches a secret is either in the table below or named in this paragraph. The first five rows are the core. The next three are where a verb decides whether a secret is asked for at all, and hand the asking itself to src/crypt.rs. The two after those are the recovery paths that also hold raw key material and must keep the same Zeroizing discipline, the two after those are where a value out of an archive nobody has vouched for is checked before it reaches a command line, and the last three are about the home being written into rather than about what is written: what a restore may take out of an archive's own config, what a move does to the key it retires, and whether the home's directories still lead into it. Two verbs are left off on purpose: src/cmd/verify.rs and src/cmd/show.rs hold an archive passphrase only for the call that carries it from read_archive_passphrase in src/cmd/mod.rs to Reader::open in src/container.rs, and decide nothing about it. Two places move a live private key, and both rename rather than delete, leave a note saying what they did, and never reuse a name a note already points at: retire_any_displaced_key in src/cmd/restore.rs, which also confirms by name first, and retire in src/cmd/migrate.rs, which is what rad backup move does to the machine it is moving off.
| Read this | To satisfy yourself that |
|---|---|
src/key.rs |
The key is parsed, decrypted and re-encrypted in memory only, and every buffer holding seed or passphrase bytes is a Zeroizing one. |
src/crypt.rs |
Archives are age, each passphrase comes from three places only and knows which of the three secrets it protects, an empty one is refused, and a wrong one is told apart from a damaged file and from a key that stayed locked. |
src/perms.rs |
"Owner only" is defined once, applied at creation rather than after it, and admits out loud when a platform cannot promise it. |
src/exec.rs |
Nothing is run through a shell, and no child process inherits a passphrase it has no use for. The list of secrets to scrub is walked through a match, so a new one cannot be added without the compiler asking where it goes. |
src/container.rs |
An archive from anywhere is hostile input: no absolute paths, no .., regular files only, no repository id that would not stay a single directory under storage/, and every entry digested against the manifest in both directions. |
src/cmd/mod.rs |
Every verb that opens an archive asks for its passphrase through read_archive_passphrase, which asks only when the archive header says one sealed it, and Ctx::identities is the one place the --identity key files and whether there is a terminal to prompt on are gathered before src/crypt.rs tries them. |
src/cmd/backup/mod.rs |
ask_encryption writes a plaintext archive only when --plaintext was given, and warns when it does; a passphrase that seals a new archive is read for Sealing, so it is typed twice. |
src/cmd/doctor.rs |
check_archive_encryption never prompts: a passphrase archive is judged from its header without being opened, and the --identity keys are tried with is_interactive forced off, so a key that stays locked is reported as a question the check could not put rather than as a failure or a prompt on a timer. check_key_protection reads the key file to say whether it is encrypted and never decrypts it. |
src/cmd/paper.rs |
The recovery sheet is the key in the clear: the mnemonic, the key file, the escaped HTML and the QR SVG are each built once, at their exact final size, in a Zeroizing buffer that is never regrown, and the one untrusted field (the alias) is HTML-escaped. The exception is inside the qrcode crate, which is not reachable from here without unsafe or a fork: its module matrix, encoder buffers and the renderer's growing String hold copies of the drawing that decode back to the key, and none of them is wiped. |
src/cmd/words.rs |
The 24 words typed to rebuild an identity arrive on a Zeroizing line and stay in Zeroizing buffers through to the key file, written at 0600. |
src/rad.rs |
An identifier taken from an archive is base58 and nothing else before it reaches rad, so a repository or node id out of a manifest cannot arrive in an argv position reading as a flag. |
src/git.rs |
A HEAD taken from an archive names a ref before it reaches git symbolic-ref, which accepts no -- and stores what it is handed without checking it, so neither a value read as a flag nor one that climbs out of the repository gets through. |
src/cmd/restore.rs |
CONFIG_ALLOWED is the boundary that stops an archive's repository config making git run a command, and CONFIG_FROM_INIT beside it is what a restore drops in silence rather than warning about; both are spelled the same way in assets/restore.sh and assets/RESTORE.md, held together by ci/pins.sh. settle_directories_that_point_elsewhere decides what a symlink at one of the home's own directories means, and refuse_a_link_that_appeared_mid_restore refuses one that was not there when that was settled. |
src/cmd/migrate.rs |
retire renames the key of the machine being moved off rather than deleting it, refuses to run while a node is up, and appends to the note beside it rather than overwriting what an earlier move left. |
src/home.rs |
directories_that_point_elsewhere answers whether a symlink stands at keys, node or storage, the three directories a restore fills. Everything that writes follows a link at a directory, so a keys aimed at a directory somebody else owns is how a private key leaves the home it was restored into. |
Five invariants those files exist to hold:
- Anything that could hold key material is created at
0600, and working directories at0700, rather than chmodded afterwards: a key that is briefly world-readable has already been read. Windows has no mode bits, and the program says so the first time it writes such a file. - A restore never writes through a link at a directory without being told to, and never silently.
keys,nodeandstorageare read before the first write; a link at one of them is named, with the place it leads to, and the run asks.--yesanswers that question yes, which is what an unattended restore into a deliberately relocatedstorageneeds, so a home an attacker can plant a link in is a home whose key an unattended restore will write to that link. That is judged the lesser hazard: planting the link needs write access to the home, which is already enough to read the key, and a recovery tool that cannot recover into the layout somebody has is worse. What is never consented to is a link that CHANGED after the question was answered: the same three names are read again once the archive is unpacked and again before the repositories go in, compared against where each led when it was settled, and any difference is refused outright,--yesincluded. A symlink at a repository's own name insidestorageis refused for that repository. At a file it writes, the link is replaced instead:write_atomicallyunlinks the name and creates it afresh at0600or0644, so nothing is written through it either. Theassets/restore.shthat rides inside every archive has nobody to ask and so refuses the whole set, leaf names included, because a shell script'scphas no unlink to fall back on. - A passphrase is never in
argv, never in a log, and never in a child process's environment.RAD_PASSPHRASEreachesradalone, becauseradis the only thing that signs with the key. - The one socket this program opens itself is the node's local control socket, which it uses to ask whether the node is running. Everything else goes through
rad: therad synca restore runs to compare what it restored with the network, and therad node stopandrad node starta restore or a--stop-nodebackup needs. A move stops no node: it refuses to run while one is up, and says so. No telemetry, no update check, no upload. unsafeis forbidden crate-wide,unwrapis a denied lint, and CI fails on either.
Two checks to run:
just repro # build it twice, get one binary
./packaging/release/verify.sh <dir> # what you downloaded is what was signedRun the second one from a rad clone, not from the copy beside the download: it trusts the allowed_signers next to itself.
Then read ARCHIVE-FORMAT.md and open an archive with nothing but age, zstd and tar.
- A machine that is already compromised. If something can read
~/.radicle/keys/radicle, it does not need this tool. - A passphrase you lose. No recovery, no escrow, no backdoor. Print a sheet with
rad backup paper. - A paper sheet with
--wordson it. Those 24 words are the key, in the clear. Anyone holding that sheet is you. - Where you put the archive. An encrypted archive on a hostile server is fine; a
--plaintextone is not, anddoctorkeeps saying so.
The newest release. Older ones get fixes only if a report explains why the newest cannot be adopted.