From e060442017a3dede12e82c49740efa3bd4cc5110 Mon Sep 17 00:00:00 2001 From: "Maciek \"mab122\" Bator" Date: Tue, 14 Jul 2026 16:01:49 +0200 Subject: [PATCH] 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=` 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. --- Cargo.lock | 39 +++ Cargo.toml | 4 + LFS-IPFS.md | 86 +++++- README.md | 11 +- crates/radicle-cli/Cargo.toml | 10 +- crates/radicle-cli/src/commands/lfs.rs | 10 +- crates/radicle-cli/src/commands/lfs/args.rs | 24 ++ crates/radicle-cli/src/commands/lfs/fetch.rs | 62 +++++ crates/radicle-cli/src/commands/lfs/init.rs | 45 ++-- crates/radicle-cli/src/commands/lfs/rekey.rs | 154 +++++++++++ crates/radicle-cli/src/commands/lfs/store.rs | 79 ++++++ crates/radicle-cli/src/ipfs.rs | 74 +++++- crates/radicle-cli/src/lfs_crypto.rs | 265 +++++++++++++++++++ crates/radicle-cli/src/lib.rs | 1 + radicle-lfs-transfer | 2 +- 15 files changed, 812 insertions(+), 54 deletions(-) create mode 100644 crates/radicle-cli/src/commands/lfs/fetch.rs create mode 100644 crates/radicle-cli/src/commands/lfs/rekey.rs create mode 100644 crates/radicle-cli/src/commands/lfs/store.rs create mode 100644 crates/radicle-cli/src/lfs_crypto.rs diff --git a/Cargo.lock b/Cargo.lock index 7ca4b0e9..bff09064 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index cbd7d499..09af4540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/LFS-IPFS.md b/LFS-IPFS.md index dfd5e158..1943b599 100644 --- a/LFS-IPFS.md +++ b/LFS-IPFS.md @@ -20,24 +20,77 @@ file-CID mapping travels with the repository itself, avoiding a single point of ## How it works -- **The mapping**: a `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 ` 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 diff --git a/README.md b/README.md index 4034a52f..adefbf71 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml index 4bd596a9..afe42506 100644 --- a/crates/radicle-cli/Cargo.toml +++ b/crates/radicle-cli/Cargo.toml @@ -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 } diff --git a/crates/radicle-cli/src/commands/lfs.rs b/crates/radicle-cli/src/commands/lfs.rs index 020dc6bd..02ecd624 100644 --- a/crates/radicle-cli/src/commands/lfs.rs +++ b/crates/radicle-cli/src/commands/lfs.rs @@ -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), } } diff --git a/crates/radicle-cli/src/commands/lfs/args.rs b/crates/radicle-cli/src/commands/lfs/args.rs index 56de93ef..eca20d31 100644 --- a/crates/radicle-cli/src/commands/lfs/args.rs +++ b/crates/radicle-cli/src/commands/lfs/args.rs @@ -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, } diff --git a/crates/radicle-cli/src/commands/lfs/fetch.rs b/crates/radicle-cli/src/commands/lfs/fetch.rs new file mode 100644 index 00000000..7d767e73 --- /dev/null +++ b/crates/radicle-cli/src/commands/lfs/fetch.rs @@ -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(()) +} diff --git a/crates/radicle-cli/src/commands/lfs/init.rs b/crates/radicle-cli/src/commands/lfs/init.rs index 59d5edc6..2fe8188f 100644 --- a/crates/radicle-cli/src/commands/lfs/init.rs +++ b/crates/radicle-cli/src/commands/lfs/init.rs @@ -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=` 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 } diff --git a/crates/radicle-cli/src/commands/lfs/rekey.rs b/crates/radicle-cli/src/commands/lfs/rekey.rs new file mode 100644 index 00000000..f946ba4d --- /dev/null +++ b/crates/radicle-cli/src/commands/lfs/rekey.rs @@ -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::(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 = enc.recipients.iter().map(|w| w.did).collect(); + let missing: Vec = 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(()) +} diff --git a/crates/radicle-cli/src/commands/lfs/store.rs b/crates/radicle-cli/src/commands/lfs/store.rs new file mode 100644 index 00000000..7484d48d --- /dev/null +++ b/crates/radicle-cli/src/commands/lfs/store.rs @@ -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(()) +} diff --git a/crates/radicle-cli/src/ipfs.rs b/crates/radicle-cli/src/ipfs.rs index abc44cbc..50651a4b 100644 --- a/crates/radicle-cli/src/ipfs.rs +++ b/crates/radicle-cli/src/ipfs.rs @@ -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=`). +//! 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 { 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::(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 { + 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> { + 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 { diff --git a/crates/radicle-cli/src/lfs_crypto.rs b/crates/radicle-cli/src/lfs_crypto.rs new file mode 100644 index 00000000..3b284ce6 --- /dev/null +++ b/crates/radicle-cli/src/lfs_crypto.rs @@ -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=` 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, +} + +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, +} + +/// 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 { + let mut set: BTreeSet = 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 { + 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, +) -> anyhow::Result<(Vec, 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 { + 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::::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> { + 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> { + 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")) +} diff --git a/crates/radicle-cli/src/lib.rs b/crates/radicle-cli/src/lib.rs index 8df1bb37..ea5a5144 100644 --- a/crates/radicle-cli/src/lib.rs +++ b/crates/radicle-cli/src/lib.rs @@ -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; diff --git a/radicle-lfs-transfer b/radicle-lfs-transfer index fb95fd0c..d67ac79f 160000 --- a/radicle-lfs-transfer +++ b/radicle-lfs-transfer @@ -1 +1 @@ -Subproject commit fb95fd0c68eb260b671839f74235d03b252b19d7 +Subproject commit d67ac79f4096622dfa16fad05fd0694e0c047a36