Add client-side encryption for private-repo LFS objects

Radicle's Visibility::Private is enforced entirely by access control at
the replication layer -- git objects for a private repo are plain,
unencrypted objects, protected only by "unauthorized peers are never
given them" (identity/doc.rs). That protection has no jurisdiction over
IPFS's own block-exchange layer: once a legitimate collaborator's IPFS
daemon pins content and joins the network, anyone who obtains the CID by
any means can fetch the plaintext directly.

Adds envelope encryption for private repos: a random per-object content
key encrypts the file (XChaCha20-Poly1305), wrapped once per authorized
recipient (delegates + private allow-list) via ECDH between Radicle
identity keys (already Ed25519, no separate keypair needed) plus HKDF.
The wrapped keys travel in the same refs/notes/rad-lfs note as the CID.

New:
- crates/radicle-cli/src/lfs_crypto.rs -- encryption core (envelope
  schema, encrypt/decrypt, key wrapping, recipient resolution, signer
  loading).
- `rad lfs store`/`rad lfs fetch` -- plumbing commands that now own the
  entire IPFS add/cat + git-notes read/write step (encrypting/decrypting
  as needed), replacing what the pre-commit hook and rad-lfs-transfer
  used to do directly. This collapses what would've become a third copy
  of the note-format logic down to one canonical implementation.
- `rad lfs rekey` -- since Radicle's access control is retroactive (a
  newly-authorized DID can replicate full history) but encrypted objects
  aren't automatically re-wrapped for new recipients, this command lets
  an already-authorized collaborator grant a newly-authorized one access
  to previously-committed objects, without re-encrypting or re-uploading
  content. Revocation remains unsolved, matching Radicle's own model
  (already-replicated/decrypted content can't be un-known).

Operational requirement: private-repo LFS operations need a signer
capable of ECDH, which only an unencrypted keystore or RAD_PASSPHRASE
provides (an ssh-agent-only signer can sign but not do key agreement).
Fails fast with an actionable error rather than hanging on a passphrase
prompt inside a non-interactive git hook.

Breaking change: the refs/notes/rad-lfs note format moves from bare
`cid=<cid>` text to JSON (`{"v":1,"cid":"...","enc":...}`). No migration
path -- prototype, no external users yet.

Verified end-to-end with real Radicle profiles/identities (not just the
transfer-agent protocol): a private repo's IPFS-stored content is
confirmed ciphertext (byte-different from plaintext, +16 bytes AEAD tag);
an authorized peer decrypts correctly; an unauthorized peer is denied
with a clear error and no leaked content; RAD_PASSPHRASE is required and
enforced with a fast, clear failure; and rekey correctly grants a
newly-authorized peer access to a pre-existing object while leaving other
recipients' entries untouched.
This commit is contained in:
Maciek "mab122" Bator 2026-07-14 16:01:49 +02:00
parent 23264b9b52
commit e060442017
15 changed files with 812 additions and 54 deletions

39
Cargo.lock generated
View File

@ -1837,6 +1837,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hkdf"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.12.1"
@ -2343,6 +2352,22 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -2968,10 +2993,14 @@ version = "0.21.0"
dependencies = [
"anyhow",
"backtrace",
"base64 0.21.7",
"chacha20poly1305",
"chrono",
"clap",
"clap_complete",
"cyphernet",
"dunce",
"hkdf",
"human-panic",
"humantime",
"itertools",
@ -2990,6 +3019,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"sha2",
"shlex",
"tempfile",
"thiserror 2.0.18",
@ -3010,6 +3040,7 @@ dependencies = [
"tree-sitter-typescript",
"ureq",
"winsplit",
"zeroize",
]
[[package]]
@ -4648,6 +4679,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-display-width"
version = "0.3.0"
@ -4713,7 +4750,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"getrandom 0.4.2",
"log",
"mime_guess",
"percent-encoding",
"ureq-proto",
"utf8-zero",

View File

@ -21,8 +21,10 @@ rust-version = "1.88.0"
[workspace.dependencies]
amplify = { version = "4.0.0", default-features = false }
backtrace = "0.3.75"
base64 = "0.21.3"
bstr = "1.3"
bytes = "1.11.1"
chacha20poly1305 = "0.10"
chrono = { version = "0.4.26", default-features = false }
colored = "2.1.0"
crossbeam-channel = "0.5.6"
@ -33,6 +35,7 @@ fastrand = { version = "2.0.0", default-features = false }
git2 = { version = "0.20.4", default-features = false, features = ["vendored-libgit2"] }
gix-hash = { version = "0.25.0", default-features = false, features = ["sha1"] }
gix-packetline = { version = "0.21.1", default-features = false }
hkdf = "0.12"
human-panic = "2.0.6"
humantime = "2.3"
indexmap = { version = "2", default-features = false }
@ -68,6 +71,7 @@ radicle-windows = { version = "0.1", path = "crates/radicle-windows" }
schemars = { version = "1.0.4", default-features = false }
serde = { version = "1.0", default-features = false }
serde_json = "1.0"
sha2 = "0.10.9"
shlex = "1.1.0"
signature = "2.2"
snapbox = "1.2"

View File

@ -20,24 +20,77 @@ file-CID mapping travels with the repository itself, avoiding a single point of
## How it works
- **The mapping**: a `cid=<cid>` git note on `refs/notes/rad-lfs`, attached to each LFS
pointer file's own git blob hash (not the LFS oid — a different value). Verified against
this repo's actual replication code (`references_of` in
`crates/radicle/src/storage/git.rs`) that Radicle replicates every ref under a peer's
namespace except `refs/tmp/heads/*` — there's no fixed allowlist, so this notes ref
replicates the same way `refs/heads`/`refs/tags`/`refs/cobs` do, once the push/fetch
refspecs below are configured.
- **On commit**: a pre-commit hook (installed by `rad lfs init`) adds each staged LFS-tracked
file to your local IPFS daemon, pins it, and writes the note above.
- **The mapping**: a JSON git note on `refs/notes/rad-lfs`, attached to each LFS pointer
file's own git blob hash (not the LFS oid — a different value), e.g. `{"v":1,"cid":"Qm..."}`
for a public-repo object. Verified against this repo's actual replication code
(`references_of` in `crates/radicle/src/storage/git.rs`) that Radicle replicates every ref
under a peer's namespace except `refs/tmp/heads/*` — there's no fixed allowlist, so this
notes ref replicates the same way `refs/heads`/`refs/tags`/`refs/cobs` do, once the
push/fetch refspecs below are configured.
- **On commit**: a pre-commit hook (installed by `rad lfs init`) computes each staged
LFS-tracked file's oid/size and shells out to `rad lfs store`, which adds it to your local
IPFS daemon, pins it, and writes the note above — encrypting the content first if the
repository is private (see below).
- **On push/pull**: the actual bytes move through
[`rad-lfs-transfer`][rad-lfs-transfer], a Git LFS custom transfer agent — see that repo for
details. It's a separate binary/repository since it has nothing Radicle-specific in it; it
only needs a local IPFS daemon and a git repository with the notes above.
details. It's a separate binary/repository since it has nothing Radicle-specific in it (no
identity, no keys, no encryption); it shells out to `rad lfs store`/`rad lfs fetch` for the
actual work.
- **Seeding**: `rad seed` now also pins a repository's known LFS objects in your local IPFS
node (mirroring "seeding = keep a full copy"); `rad unseed` unpins them. Known prototype
limitation: no cross-repository pin refcounting, so unseeding one repository can unpin
content still needed by another seeded repository that happens to share the same CID.
## Encryption for private repositories
Radicle's `Visibility::Private` is enforced entirely by access control at the replication
layer — git objects for a private repo are plain, unencrypted objects, protected only by
"unauthorized peers are never given them" (see `crates/radicle/src/identity/doc.rs`). That
protection has no jurisdiction over IPFS's own block-exchange layer: once a legitimate
collaborator's IPFS daemon pins content and joins the network, anyone who obtains the CID by
any means can fetch the plaintext directly. So for private repositories, `rad lfs store`
encrypts content before it ever reaches IPFS:
- A random per-object content key (CEK) encrypts the file (XChaCha20-Poly1305).
- The CEK is wrapped once per authorized recipient (repo delegates + the private allow-list),
using ECDH between the committer's and each recipient's Radicle identity key (Ed25519,
directly on the curve — no separate encryption keypair to manage) plus HKDF, so only
identities Radicle already considers authorized for the repo can decrypt.
- The wrapped keys travel in the same git note as the CID — no separate key-distribution
mechanism, no server.
**This requires a signer capable of key agreement (ECDH), not just signing.** An ssh-agent-only
signer can sign but can't expose the key material ECDH needs. Concretely: `rad lfs store`/
`rad lfs fetch`/`rad lfs rekey` on a *private* repository require either an unencrypted
keystore, or `RAD_PASSPHRASE` set in the environment — including inside the pre-commit hook at
commit time. If neither is available, these commands fail immediately with a clear message
telling you to set `RAD_PASSPHRASE`, rather than hanging on a passphrase prompt inside a
non-interactive hook. Public repositories are entirely unaffected — no keys, no ECDH, same
plaintext behavior as before.
### Granting access to existing objects: `rad lfs rekey`
Radicle's access control is retroactive: once a DID is authorized, it can replicate a
repository's entire history, including everything from before it was added. Without extra
handling, this encryption design would *not* match that: a newly-authorized collaborator could
see a private repo's full git history but be permanently unable to decrypt any LFS object
committed before they were granted access, since those objects' wrapped-key lists only include
whoever was authorized at the time.
`rad lfs rekey` closes that gap. Run by anyone *already* authorized (it can't be self-served by
the new collaborator — they have nothing to decrypt yet), it walks every encrypted object,
recovers each one's content key using the runner's own existing access, and adds a wrapped-key
entry for any currently-authorized DID that's missing one. It never re-encrypts content or
touches IPFS — it's a pure git-notes operation, so it's fast and doesn't need the original
file. Run it after adding a new delegate or allow-listing a new DID on a private repository, if
you want them to have access to previously-committed LFS objects (not automatic — see
[Prerequisites](#prerequisites)/[Use](#use) below for the full flow).
**Still explicitly not solved**: revocation. A formerly-authorized peer who already decrypted
an object can't be made to un-know its content key — the same fundamental limitation as
Radicle's own replication-based access control (already-replicated git objects can't be
un-seen either), not a regression introduced by this design.
[rad-lfs-transfer]: https://git.hswro.org/mab122/radicle-lfs-transfer
## Prerequisites
@ -131,6 +184,13 @@ git lfs pull # fetches large files via IPFS instead of git
IPFS node, so seeding a repository keeps a full copy of its large files too, not just its git
history.
**On a private repository**, make sure `RAD_PASSPHRASE` is set (or your keystore is
unencrypted) before committing — see [Encryption for private repositories](#encryption-for-private-repositories)
above. After granting a new collaborator access (`rad id update --allow <did>` or adding them
as a delegate), have an already-authorized collaborator run `rad lfs rekey` and push, so the
new collaborator can decrypt previously-committed LFS objects too, not just ones committed
after they were added.
### What happens without an IPFS daemon running
- `rad lfs init` checks upfront and refuses with a clear message
@ -150,7 +210,9 @@ history.
| `Git LFS is not installed (the 'git-lfs' binary was not found on your PATH)` | Install [Git LFS](https://git-lfs.com). |
| `no IPFS (Kubo) daemon reachable at ...` | Start one: `ipfs daemon` (see [Run](#run) above). |
| `rad-lfs-transfer: command not found` during `git lfs push`/`pull` | `cargo install --path radicle-lfs-transfer` didn't complete, or `~/.radicle/bin` isn't on `PATH`. |
| Large file didn't get pinned (commit went through, but no `rad-lfs` note) | The pre-commit hook silently skips pinning if the `ipfs` CLI isn't on `PATH` — check `command -v ipfs`. Re-add the file and commit again once it is. |
| Large file didn't get pinned (commit went through, but no `rad-lfs` note) | The pre-commit hook silently skips pinning if the `ipfs`/`rad` CLIs aren't on `PATH` — check `command -v ipfs` / `command -v rad`. Re-add the file and commit again once available. |
| `private-repo LFS operations need to perform a key-agreement (ECDH) operation ... Set RAD_PASSPHRASE` | Your keystore is encrypted and no passphrase is available (an ssh-agent-only signer can't do key agreement). Set `RAD_PASSPHRASE` in the environment the commit/fetch runs in, or use an unencrypted keystore. |
| `you are not an authorized recipient of this object` | Either genuinely unauthorized (not a delegate/allow-listed DID for this private repo), or authorized *after* this specific object was committed and nobody has run `rad lfs rekey` since — ask an already-authorized collaborator to run it and push. |
## Status

View File

@ -48,10 +48,13 @@
> ```
>
> From here, use `rad` exactly as upstream describes below (`rad auth`, `rad init`, etc). The
> only new command is `rad lfs init`, run once inside a repository you want large-file support
> in — see [`LFS-IPFS.md`](LFS-IPFS.md) for that workflow. **Nothing above requires IPFS**;
> Git LFS support specifically needs a running `ipfs daemon`, and `rad lfs init` will tell you
> plainly if one isn't reachable rather than failing confusingly later.
> only new commands are `rad lfs init` (run once per repository you want large-file support in)
> and `rad lfs rekey` (run after granting a new collaborator access to a **private** repository,
> so they can decrypt previously-committed LFS objects too) — see [`LFS-IPFS.md`](LFS-IPFS.md)
> for that workflow and how private-repo content gets encrypted before it reaches IPFS.
> **Nothing above requires IPFS**; Git LFS support specifically needs a running `ipfs daemon`,
> and `rad lfs init` will tell you plainly if one isn't reachable rather than failing confusingly
> later.
---

View File

@ -20,10 +20,14 @@ tor = ["radicle/tor"]
[dependencies]
anyhow = "1"
base64 = { workspace = true }
chacha20poly1305 = { workspace = true }
chrono = { workspace = true, features = ["clock", "std"] }
clap = { version = "4.5.44", features = ["derive"] }
clap_complete = "4.5"
cyphernet = { workspace = true, features = ["ed25519"] }
dunce = { workspace = true }
hkdf = { workspace = true }
human-panic.workspace = true
humantime.workspace = true
itertools.workspace = true
@ -31,7 +35,7 @@ log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
radicle = { workspace = true, features = ["schemars"] }
radicle-cob = { workspace = true }
radicle-crypto = { workspace = true }
radicle-crypto = { workspace = true, features = ["cyphernet"] }
radicle-localtime = { workspace = true }
radicle-surf = { workspace = true }
radicle-term = { workspace = true }
@ -39,9 +43,11 @@ radicle-log = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true, default-features = true }
timeago = { version = "0.4.2", default-features = false }
zeroize = { workspace = true }
tree-sitter = "0.24.4"
tree-sitter-bash = "0.23.3"
tree-sitter-c = "0.23.2"
@ -56,7 +62,7 @@ tree-sitter-ruby = "0.23.1"
tree-sitter-rust = "0.23.2"
tree-sitter-toml-ng = "0.6.0"
tree-sitter-typescript = "0.23.2"
ureq = { workspace = true }
ureq = { workspace = true, features = ["multipart"] }
[target.'cfg(unix)'.dependencies]
backtrace = { workspace = true, optional = true }

View File

@ -1,6 +1,9 @@
//! `rad lfs` command implementation.
pub mod fetch;
pub mod init;
pub mod rekey;
pub mod store;
mod args;
@ -10,11 +13,10 @@ pub use args::Args;
use args::Command;
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
// No subcommand currently needs a `Profile`, but we take `ctx` to stay
// consistent with the other top-level commands' `run` signature.
let _ = ctx;
match args.command {
Command::Init => self::init::run(),
Command::Store { oid, size, path } => self::store::run(oid, size, path, ctx),
Command::Fetch { oid, size, out } => self::fetch::run(oid, size, out, ctx),
Command::Rekey => self::rekey::run(ctx),
}
}

View File

@ -22,4 +22,28 @@ pub struct Args {
pub enum Command {
/// Set up Git LFS support for this repository, backed by IPFS
Init,
/// Store an LFS object's content in IPFS (invoked by the custom
/// transfer agent; not for interactive use)
#[command(hide = true)]
Store {
#[arg(long)]
oid: String,
#[arg(long)]
size: i64,
path: std::path::PathBuf,
},
/// Fetch an LFS object's content from IPFS (invoked by the custom
/// transfer agent; not for interactive use)
#[command(hide = true)]
Fetch {
#[arg(long)]
oid: String,
#[arg(long)]
size: i64,
#[arg(long)]
out: std::path::PathBuf,
},
/// Grant newly-authorized collaborators access to previously-encrypted
/// LFS objects
Rekey,
}

View File

@ -0,0 +1,62 @@
//! `rad lfs fetch` — plumbing command invoked by the Git LFS custom
//! transfer agent (`rad-lfs-transfer`) to download one LFS object's
//! content from the local IPFS (Kubo) node, decrypting it first if the
//! repository is private.
use std::path::PathBuf;
use anyhow::{Context as _, anyhow};
use crate::ipfs;
use crate::lfs_crypto::{self, Envelope};
use crate::terminal as term;
pub fn run(
oid: String,
size: i64,
out: PathBuf,
ctx: impl term::Context,
) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs fetch` must be run inside a Radicle repository working copy")?;
let pointer_text =
format!("version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n");
let blob_oid = repo
.blob(pointer_text.as_bytes())
.context("failed to write LFS pointer blob")?;
let note = repo.find_note(Some(ipfs::LFS_NOTES_REF), blob_oid).map_err(|_| {
anyhow!(
"no CID recorded for oid {oid}; the peer that has this object needs to push its \
{} ref",
ipfs::LFS_NOTES_REF
)
})?;
let message = note.message().ok_or_else(|| {
anyhow!(
"no CID recorded for oid {oid}; the peer that has this object needs to push its \
{} ref",
ipfs::LFS_NOTES_REF
)
})?;
let envelope: Envelope = serde_json::from_str(message)
.with_context(|| format!("failed to parse LFS note for oid {oid}"))?;
ipfs::check_daemon()?;
let bytes = match &envelope.enc {
None => ipfs::cat(&envelope.cid)?,
Some(enc) => {
let ciphertext = ipfs::cat(&envelope.cid)?;
let signer = lfs_crypto::load_signer(&profile)?;
lfs_crypto::decrypt_with(&ciphertext, enc, &signer, profile.did(), &rid, &oid)?
}
};
std::fs::write(&out, bytes)
.with_context(|| format!("failed to write `{}`", out.display()))?;
Ok(())
}

View File

@ -42,21 +42,28 @@ const HOOK_END: &str = "# <<< rad-lfs-init managed pre-commit hook <<<";
///
/// For every file staged in the commit that's tracked by Git LFS (i.e.
/// matches a `filter=lfs` pattern in `.gitattributes`), this:
/// 1. `ipfs add`s the real file content to the local Kubo daemon,
/// 2. `ipfs pin add`s the resulting CID,
/// 3. computes the git blob hash of the LFS pointer text for that file, and
/// 4. writes a `cid=<cid>` git note on that blob hash under
/// `refs/notes/rad-lfs`.
/// 1. computes the file's LFS oid (sha256) and size, and
/// 2. shells out to `rad lfs store` with those values, which does the
/// actual IPFS add/pin (encrypting first for private repos) and writes
/// the corresponding note on `refs/notes/rad-lfs` itself.
///
/// This mirrors `radicle-lfs-server`'s `notes::pointer_blob_hash` /
/// `notes::write_cid` logic (see that crate's `src/notes.rs`), reimplemented
/// here as a shell script since a git hook has no business depending on a
/// separate Rust crate.
/// The hook only computes the cheap, dependency-free oid/size values in
/// shell; the CID/encryption/git-notes logic lives in `rad lfs store`
/// (see `commands/lfs/store.rs`) since it needs access to the repo's
/// identity/visibility and (for private repos) key material that a plain
/// shell script has no business handling — a git hook shelling out to our
/// own `rad` binary is far simpler than reimplementing that here.
///
/// Requires both `ipfs` and `rad` to be available on `PATH` at commit time.
const HOOK_BODY: &str = r#"rad_lfs_precommit() {
command -v ipfs >/dev/null 2>&1 || {
echo "rad-lfs: 'ipfs' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0
}
command -v rad >/dev/null 2>&1 || {
echo "rad-lfs: 'rad' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0
}
sha256() {
if command -v sha256sum >/dev/null 2>&1; then
@ -79,24 +86,8 @@ const HOOK_BODY: &str = r#"rad_lfs_precommit() {
oid=$(sha256 "$file") || { rm -f "$staged"; exit 1; }
size=$(wc -c < "$file" | tr -d ' ')
cid=$(ipfs add -Q -- "$file") || {
echo "rad-lfs: 'ipfs add' failed for $file" >&2
rm -f "$staged"
exit 1
}
ipfs pin add -- "$cid" >/dev/null || {
echo "rad-lfs: 'ipfs pin add' failed for $cid" >&2
rm -f "$staged"
exit 1
}
blob_sha=$(printf 'version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %s\n' "$oid" "$size" | git hash-object --stdin -t blob) || {
rm -f "$staged"
exit 1
}
git notes --ref=refs/notes/rad-lfs add -f -m "cid=$cid" "$blob_sha" >/dev/null || {
echo "rad-lfs: failed to write git note for $file" >&2
rad lfs store --oid "$oid" --size "$size" -- "$file" >/dev/null || {
echo "rad-lfs: 'rad lfs store' failed for $file" >&2
rm -f "$staged"
exit 1
}

View File

@ -0,0 +1,154 @@
//! `rad lfs rekey` — grants newly-authorized collaborators (new delegates,
//! or newly-added entries in a private repo's allow-list) access to
//! previously-encrypted LFS objects, without re-encrypting or re-uploading
//! any content: it only adds new wrapped-key entries to each object's
//! note for recipients who are missing one.
use std::collections::BTreeSet;
use anyhow::Context as _;
use radicle::git::raw::Signature;
use radicle::identity::Did;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::ipfs;
use crate::lfs_crypto::{self, Envelope};
use crate::terminal as term;
const NOTE_AUTHOR_NAME: &str = "rad-lfs";
const NOTE_AUTHOR_EMAIL: &str = "rad-lfs@localhost";
pub fn run(ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs rekey` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let signer = lfs_crypto::load_signer(&profile)?;
let my_did = profile.did();
// The full set of currently-authorized DIDs: delegates the private
// allow-list `my_did` itself (the `sender` parameter of
// `recipients_for` is only meaningful for `encrypt_for`'s "make sure
// the committer can read their own object back" guarantee; here we
// just want the full authorized set, and `my_did` is necessarily
// already a member of it since running `rekey` requires being
// authorized).
let current = lfs_crypto::recipients_for(&doc, my_did);
let notes = match repo.notes(Some(ipfs::LFS_NOTES_REF)) {
Ok(notes) => notes,
Err(_) => {
term::success!("Nothing to rekey: no LFS objects are tracked in this repository yet");
return Ok(());
}
};
let signature = Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
let mut rekeyed_objects = 0;
let mut added_recipients = 0;
for note in notes {
let Ok((_, target_oid)) = note else {
continue;
};
let Ok(note) = repo.find_note(Some(ipfs::LFS_NOTES_REF), target_oid) else {
continue;
};
let Some(message) = note.message() else {
continue;
};
let Ok(mut envelope) = serde_json::from_str::<Envelope>(message) else {
continue;
};
let Some(enc) = envelope.enc.as_mut() else {
// Public (unencrypted) object: nothing to rekey.
continue;
};
// The note only stores the CID and encryption metadata, not the
// oid/size that were used as HKDF context when wrapping the
// content key. Recover them losslessly from the pointer blob
// itself: `target_oid` *is* the git blob hash of the exact
// deterministic pointer text `store`/`fetch` compute from
// oid/size, so we can parse it back out.
let Ok(blob) = repo.find_blob(target_oid) else {
continue;
};
let Ok(pointer_text) = std::str::from_utf8(blob.content()) else {
continue;
};
let Some(oid) = pointer_text
.lines()
.find_map(|line| line.strip_prefix("oid sha256:"))
else {
continue;
};
let cek = match lfs_crypto::unwrap_cek(&enc.recipients, &signer, my_did, &rid, oid) {
Ok(cek) => cek,
Err(err) => {
term::warning(format!(
"Skipping object with oid {oid}: {err} (you may not have been an authorized \
recipient of this particular object)"
));
continue;
}
};
let existing: BTreeSet<Did> = enc.recipients.iter().map(|w| w.did).collect();
let missing: Vec<Did> = current.difference(&existing).copied().collect();
if missing.is_empty() {
continue;
}
let cek_key: chacha20poly1305::Key = (*cek).into();
let mut any_added = false;
for recipient in &missing {
match lfs_crypto::wrap_cek_for(&cek_key, &signer, my_did, recipient, &rid, oid) {
Ok(wrapped) => {
enc.recipients.push(wrapped);
added_recipients += 1;
any_added = true;
}
Err(err) => {
term::warning(format!(
"Failed to wrap the content key for {recipient} on object with oid \
{oid}: {err}"
));
}
}
}
if !any_added {
continue;
}
let message = serde_json::to_string(&envelope)
.context("failed to serialize LFS note envelope")?;
repo.note(
&signature,
&signature,
Some(ipfs::LFS_NOTES_REF),
target_oid,
&message,
true,
)
.context("failed to rewrite LFS note")?;
rekeyed_objects += 1;
}
if rekeyed_objects == 0 {
term::success!("Nothing to rekey: all recorded recipients already have access");
} else {
term::success!(
"Rekeyed {rekeyed_objects} object(s), added {added_recipients} new recipient(s)"
);
}
Ok(())
}

View File

@ -0,0 +1,79 @@
//! `rad lfs store` — plumbing command invoked by the Git LFS custom
//! transfer agent (`rad-lfs-transfer`) to upload one LFS object's content
//! to the local IPFS (Kubo) node, encrypting it first if the repository
//! is private.
use std::path::PathBuf;
use anyhow::Context as _;
use radicle::git::raw::Signature;
use radicle::storage::{ReadRepository as _, ReadStorage as _};
use crate::ipfs;
use crate::lfs_crypto::{self, Envelope};
use crate::terminal as term;
/// Committer identity used for the `refs/notes/rad-lfs` notes this command
/// writes. There's no natural "author" for a note that just records a
/// CID, so a fixed identity is used, matching the shell version of this
/// logic in the `rad lfs init`-installed pre-commit hook.
const NOTE_AUTHOR_NAME: &str = "rad-lfs";
const NOTE_AUTHOR_EMAIL: &str = "rad-lfs@localhost";
pub fn run(oid: String, size: i64, path: PathBuf, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let (repo, rid) = radicle::rad::cwd()
.context("`rad lfs store` must be run inside a Radicle repository working copy")?;
let doc = profile.storage.repository(rid)?.identity_doc()?.doc;
let pointer_text =
format!("version https://git-lfs.github.com/spec/v1\noid sha256:{oid}\nsize {size}\n");
let blob_oid = repo
.blob(pointer_text.as_bytes())
.context("failed to write LFS pointer blob")?;
ipfs::check_daemon()?;
let envelope = if doc.visibility().is_public() {
let bytes = std::fs::read(&path)
.with_context(|| format!("failed to read `{}`", path.display()))?;
let cid = ipfs::add(&bytes)?;
let _ = ipfs::pin_all(std::slice::from_ref(&cid));
Envelope::plain(cid)
} else {
let signer = lfs_crypto::load_signer(&profile)?;
let sender_did = profile.did();
let recipients = lfs_crypto::recipients_for(&doc, sender_did);
let plaintext = std::fs::read(&path)
.with_context(|| format!("failed to read `{}`", path.display()))?;
let (ciphertext, enc) =
lfs_crypto::encrypt_for(&plaintext, &signer, sender_did, &rid, &oid, &recipients)?;
let cid = ipfs::add(&ciphertext)?;
let _ = ipfs::pin_all(std::slice::from_ref(&cid));
Envelope {
v: 1,
cid,
enc: Some(enc),
}
};
let cid = envelope.cid.clone();
let message =
serde_json::to_string(&envelope).context("failed to serialize LFS note envelope")?;
let signature = Signature::now(NOTE_AUTHOR_NAME, NOTE_AUTHOR_EMAIL)
.context("failed to construct note signature")?;
repo.note(
&signature,
&signature,
Some(ipfs::LFS_NOTES_REF),
blob_oid,
&message,
true,
)
.context("failed to write LFS note")?;
term::println(cid);
Ok(())
}

View File

@ -3,8 +3,10 @@
//! This is a prototype of "Git-LFS over IPFS": large files tracked via
//! Git LFS are stored in IPFS rather than as git blobs, and the mapping
//! from an LFS pointer file's git blob oid to the corresponding IPFS CID
//! is recorded as a git note on `refs/notes/rad-lfs` (note content
//! `cid=<cid>`).
//! is recorded as a git note on `refs/notes/rad-lfs` (note content is a
//! JSON [`crate::lfs_crypto::Envelope`], e.g. `{"v":1,"cid":"Qm...",
//! "enc":null}` for a public repo, or with populated `enc` metadata for
//! a private one).
//!
//! Since seeding a repository already means "keep a full local copy of
//! it", we extend that lifecycle to IPFS: seeding a repository pins all
@ -70,13 +72,77 @@ pub fn lfs_cids(repo: &Repository) -> Vec<String> {
let Some(message) = note.message() else {
continue;
};
if let Some(cid) = message.trim().strip_prefix("cid=") {
cids.push(cid.to_string());
if let Ok(envelope) = serde_json::from_str::<crate::lfs_crypto::Envelope>(message) {
cids.push(envelope.cid);
}
}
cids
}
/// Add `bytes` to the local IPFS node as a single blob, returning its CID.
pub fn add(bytes: &[u8]) -> anyhow::Result<String> {
use ureq::unversioned::multipart::{Form, Part};
let base = kubo_api_url();
let url = format!("{base}/api/v0/add");
let form = Form::new().part("file", Part::bytes(bytes).file_name("blob"));
let mut response = ureq::post(&url).send(form).map_err(|err| {
if is_daemon_unreachable(&err) {
anyhow!(
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
This repo's large-file (LFS) support is backed by your local IPFS node."
)
} else {
anyhow!("failed to add object to IPFS: {err}")
}
})?;
let body = response
.body_mut()
.read_to_string()
.map_err(|err| anyhow!("failed to read IPFS `add` response: {err}"))?;
// The response is newline-delimited JSON, one object per added file;
// we only ever add a single blob per call, so the first line is all
// we need.
let line = body
.lines()
.next()
.ok_or_else(|| anyhow!("empty response from IPFS `add`"))?;
let value: serde_json::Value = serde_json::from_str(line)
.map_err(|err| anyhow!("failed to parse IPFS `add` response: {err}"))?;
let hash = value
.get("Hash")
.and_then(|h| h.as_str())
.ok_or_else(|| anyhow!("IPFS `add` response missing a `Hash` field"))?;
Ok(hash.to_string())
}
/// Fetch the raw bytes of the object identified by `cid` from the local
/// IPFS node.
pub fn cat(cid: &str) -> anyhow::Result<Vec<u8>> {
let base = kubo_api_url();
let url = format!("{base}/api/v0/cat?arg={cid}");
let mut response = ureq::post(&url).send_empty().map_err(|err| {
if is_daemon_unreachable(&err) {
anyhow!(
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
This repo's large-file (LFS) support is backed by your local IPFS node."
)
} else {
anyhow!("failed to fetch object {cid} from IPFS: {err}")
}
})?;
response
.body_mut()
.read_to_vec()
.map_err(|err| anyhow!("failed to read IPFS `cat` response for {cid}: {err}"))
}
/// Whether a `ureq` error indicates that the local IPFS daemon could
/// not be reached at all, as opposed to an HTTP-level error response.
fn is_daemon_unreachable(err: &ureq::Error) -> bool {

View File

@ -0,0 +1,265 @@
//! Client-side encryption for Git-LFS-over-IPFS objects on private
//! repositories.
//!
//! Radicle's `Visibility::Private` is enforced entirely by access control
//! at the replication layer -- git objects for a private repo are plain,
//! unencrypted objects, protected only by "unauthorized peers are never
//! given them". That protection has no jurisdiction over IPFS's own
//! block-exchange layer: once a legitimate collaborator's IPFS daemon
//! pins LFS content and joins the network, anyone who obtains the CID by
//! any means can fetch the plaintext directly. This module makes sure
//! IPFS only ever stores ciphertext for private repos, using Radicle's
//! own identity keys via envelope encryption (a random per-object content
//! key, wrapped once per authorized recipient).
use std::collections::BTreeSet;
use anyhow::{Context as _, anyhow, bail};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use chacha20poly1305::aead::rand_core::RngCore;
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use cyphernet::Ecdh;
use hkdf::Hkdf;
use radicle::Profile;
use radicle::identity::{Did, Doc, RepoId, Visibility};
use radicle_crypto::ssh::keystore::MemorySigner;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use zeroize::Zeroizing;
pub const ENVELOPE_VERSION: u32 = 1;
const HKDF_DOMAIN: &[u8] = b"radicle-lfs-wrap-v1";
const ALG: &str = "xchacha20poly1305";
/// Note content stored on `refs/notes/rad-lfs`, replacing the earlier
/// bare `cid=<cid>` text. `enc` is `None` for public repos (plain
/// passthrough, unchanged behavior).
#[derive(Debug, Serialize, Deserialize)]
pub struct Envelope {
pub v: u32,
pub cid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enc: Option<Encryption>,
}
impl Envelope {
#[must_use]
pub fn plain(cid: String) -> Self {
Self {
v: ENVELOPE_VERSION,
cid,
enc: None,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Encryption {
pub alg: String,
/// Nonce for the content AEAD. Fixed for the object's life -- `rekey`
/// never re-encrypts content, only adds wrapped-key entries.
pub content_nonce: String,
pub recipients: Vec<WrappedKey>,
}
/// One recipient's wrapped copy of the object's content-encryption key.
/// `sender_did` is per-entry, not per-envelope: a `rekey` operation is
/// performed by a different identity (using their own key) than whoever
/// originally committed the object, so each entry must record who
/// actually produced it.
#[derive(Debug, Serialize, Deserialize)]
pub struct WrappedKey {
pub did: Did,
pub sender_did: Did,
pub salt: String,
pub wrapped_cek: String,
}
/// Recipients authorized for a repository: delegates, plus the private
/// allow-list (empty for public repos), plus the sender themselves --
/// don't rely on "delegates always includes the author", make it explicit
/// so the committer can always fetch their own commits back.
#[must_use]
pub fn recipients_for(doc: &Doc, sender: Did) -> BTreeSet<Did> {
let mut set: BTreeSet<Did> = doc.delegates().iter().copied().collect();
if let Visibility::Private { allow } = doc.visibility() {
set.extend(allow.iter().copied());
}
set.insert(sender);
set
}
/// Loads a signer capable of ECDH. Only `MemorySigner` implements the
/// `Ecdh` trait -- an ssh-agent-backed signer can sign but can't expose
/// the raw key material a key-agreement operation needs. Fails fast with
/// an actionable message instead of hanging on a passphrase prompt, which
/// matters since this may run inside a non-interactive git hook.
pub fn load_signer(profile: &Profile) -> anyhow::Result<MemorySigner> {
if !profile.keystore.is_encrypted()? {
return Ok(MemorySigner::load(&profile.keystore, None)?);
}
if let Some(passphrase) = radicle::profile::env::passphrase() {
return Ok(MemorySigner::load(&profile.keystore, Some(passphrase))?);
}
bail!(
"private-repo LFS operations need to perform a key-agreement (ECDH) operation, which \
requires direct access to your secret key -- an ssh-agent-only signer can't do this. \
Set RAD_PASSPHRASE (or use an unencrypted keystore) and try again."
)
}
/// Encrypts `plaintext` with a fresh content key, then wraps that key once
/// per recipient. Returns the ciphertext to store in IPFS and the
/// encryption metadata to record in the note.
pub fn encrypt_for(
plaintext: &[u8],
sender: &MemorySigner,
sender_did: Did,
rid: &RepoId,
oid: &str,
recipients: &BTreeSet<Did>,
) -> anyhow::Result<(Vec<u8>, Encryption)> {
let cek = XChaCha20Poly1305::generate_key(&mut OsRng);
let content_nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let cipher = XChaCha20Poly1305::new(&cek);
let ciphertext = cipher
.encrypt(&content_nonce, plaintext)
.map_err(|_| anyhow!("content encryption failed"))?;
let mut wrapped = Vec::with_capacity(recipients.len());
for recipient in recipients {
wrapped.push(wrap_cek_for(&cek, sender, sender_did, recipient, rid, oid)?);
}
Ok((
ciphertext,
Encryption {
alg: ALG.to_string(),
content_nonce: BASE64.encode(content_nonce),
recipients: wrapped,
},
))
}
pub fn wrap_cek_for(
cek: &Key,
sender: &MemorySigner,
sender_did: Did,
recipient: &Did,
rid: &RepoId,
oid: &str,
) -> anyhow::Result<WrappedKey> {
let shared = sender
.ecdh(recipient.as_key())
.map_err(|e| anyhow!("ECDH with {recipient} failed: {e}"))?;
let mut salt = [0u8; 16];
OsRng.fill_bytes(&mut salt);
let wrap_key = derive_wrap_key(&shared, &salt, rid, oid, &sender_did, recipient);
// Fixed all-zero nonce is safe here: `wrap_key` is derived via HKDF
// from a salt that's freshly generated for this exact
// (rid, oid, sender, recipient) wrap operation, so it is never reused
// to encrypt more than this one 32-byte CEK -- key-once nonce reuse
// is not a vulnerability.
let cipher = XChaCha20Poly1305::new(&wrap_key);
let wrapped_cek = cipher
.encrypt(&XNonce::default(), cek.as_slice())
.map_err(|_| anyhow!("failed to wrap key for {recipient}"))?;
Ok(WrappedKey {
did: *recipient,
sender_did,
salt: BASE64.encode(salt),
wrapped_cek: BASE64.encode(wrapped_cek),
})
}
/// HKDF context mixes in more than the raw ECDH shared secret: the shared
/// secret between two fixed DIDs is *static*, reused across every object
/// they ever exchange. Without per-object context here, the "nonce fixed
/// because key-once" argument in `wrap_cek_for` would be false -- the same
/// key could end up wrapping multiple different CEKs. Mixing in `rid` +
/// `oid` (plus a fresh random `salt`) makes every wrap operation
/// cryptographically independent, even between the same two DIDs.
fn derive_wrap_key(
shared: &[u8; 32],
salt: &[u8],
rid: &RepoId,
oid: &str,
sender_did: &Did,
recipient_did: &Did,
) -> Key {
let hk = Hkdf::<Sha256>::new(Some(salt), shared);
let mut info = Vec::new();
info.extend_from_slice(HKDF_DOMAIN);
info.extend_from_slice(rid.to_string().as_bytes());
info.extend_from_slice(oid.as_bytes());
info.extend_from_slice(sender_did.to_string().as_bytes());
info.extend_from_slice(recipient_did.to_string().as_bytes());
let mut out = [0u8; 32];
hk.expand(&info, &mut out)
.expect("32 is a valid HKDF-SHA256 output length");
out.into()
}
/// Recovers the content-encryption key from the caller's own entry among a
/// set of wrapped keys, without needing the ciphertext at all -- this is
/// the exact primitive `rad lfs rekey` needs (it only adds new wrapped-key
/// entries for the same never-changing CEK, it never re-encrypts content).
pub fn unwrap_cek(
recipients: &[WrappedKey],
me: &MemorySigner,
my_did: Did,
rid: &RepoId,
oid: &str,
) -> anyhow::Result<Zeroizing<[u8; 32]>> {
let entry = recipients
.iter()
.find(|w| w.did == my_did)
.ok_or_else(|| anyhow!("you are not an authorized recipient of this object"))?;
let shared = me
.ecdh(entry.sender_did.as_key())
.map_err(|e| anyhow!("ECDH with {} failed: {e}", entry.sender_did))?;
let salt = BASE64
.decode(&entry.salt)
.context("invalid salt in wrapped key entry")?;
let wrap_key = derive_wrap_key(&shared, &salt, rid, oid, &entry.sender_did, &my_did);
let wrapped_cek = BASE64
.decode(&entry.wrapped_cek)
.context("invalid wrapped content key")?;
let cipher = XChaCha20Poly1305::new(&wrap_key);
let cek = cipher
.decrypt(&XNonce::default(), wrapped_cek.as_slice())
.map_err(|_| anyhow!("failed to unwrap key -- wrong key or corrupted data"))?;
let mut out = [0u8; 32];
out.copy_from_slice(&cek);
Ok(Zeroizing::new(out))
}
/// Decrypts object content using the caller's own wrapped-key entry.
pub fn decrypt_with(
ciphertext: &[u8],
enc: &Encryption,
me: &MemorySigner,
my_did: Did,
rid: &RepoId,
oid: &str,
) -> anyhow::Result<Vec<u8>> {
let cek = unwrap_cek(&enc.recipients, me, my_did, rid, oid)?;
let nonce_bytes = BASE64
.decode(&enc.content_nonce)
.context("invalid content nonce")?;
let nonce = XNonce::from_slice(&nonce_bytes);
let key: Key = (*cek).into();
let cipher = XChaCha20Poly1305::new(&key);
cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow!("content decryption failed -- wrong key or corrupted data"))
}

View File

@ -5,6 +5,7 @@
pub mod commands;
pub mod git;
pub mod ipfs;
pub mod lfs_crypto;
pub mod node;
pub mod pager;
pub mod project;

@ -1 +1 @@
Subproject commit fb95fd0c68eb260b671839f74235d03b252b19d7
Subproject commit d67ac79f4096622dfa16fad05fd0694e0c047a36