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

View File

@ -21,8 +21,10 @@ rust-version = "1.88.0"
[workspace.dependencies] [workspace.dependencies]
amplify = { version = "4.0.0", default-features = false } amplify = { version = "4.0.0", default-features = false }
backtrace = "0.3.75" backtrace = "0.3.75"
base64 = "0.21.3"
bstr = "1.3" bstr = "1.3"
bytes = "1.11.1" bytes = "1.11.1"
chacha20poly1305 = "0.10"
chrono = { version = "0.4.26", default-features = false } chrono = { version = "0.4.26", default-features = false }
colored = "2.1.0" colored = "2.1.0"
crossbeam-channel = "0.5.6" 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"] } git2 = { version = "0.20.4", default-features = false, features = ["vendored-libgit2"] }
gix-hash = { version = "0.25.0", default-features = false, features = ["sha1"] } gix-hash = { version = "0.25.0", default-features = false, features = ["sha1"] }
gix-packetline = { version = "0.21.1", default-features = false } gix-packetline = { version = "0.21.1", default-features = false }
hkdf = "0.12"
human-panic = "2.0.6" human-panic = "2.0.6"
humantime = "2.3" humantime = "2.3"
indexmap = { version = "2", default-features = false } 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 } schemars = { version = "1.0.4", default-features = false }
serde = { version = "1.0", default-features = false } serde = { version = "1.0", default-features = false }
serde_json = "1.0" serde_json = "1.0"
sha2 = "0.10.9"
shlex = "1.1.0" shlex = "1.1.0"
signature = "2.2" signature = "2.2"
snapbox = "1.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 ## How it works
- **The mapping**: a `cid=<cid>` git note on `refs/notes/rad-lfs`, attached to each LFS - **The mapping**: a JSON git note on `refs/notes/rad-lfs`, attached to each LFS pointer
pointer file's own git blob hash (not the LFS oid — a different value). Verified against file's own git blob hash (not the LFS oid — a different value), e.g. `{"v":1,"cid":"Qm..."}`
this repo's actual replication code (`references_of` in for a public-repo object. Verified against this repo's actual replication code
`crates/radicle/src/storage/git.rs`) that Radicle replicates every ref under a peer's (`references_of` in `crates/radicle/src/storage/git.rs`) that Radicle replicates every ref
namespace except `refs/tmp/heads/*` — there's no fixed allowlist, so this notes ref under a peer's namespace except `refs/tmp/heads/*` — there's no fixed allowlist, so this
replicates the same way `refs/heads`/`refs/tags`/`refs/cobs` do, once the push/fetch notes ref replicates the same way `refs/heads`/`refs/tags`/`refs/cobs` do, once the
refspecs below are configured. push/fetch refspecs below are configured.
- **On commit**: a pre-commit hook (installed by `rad lfs init`) adds each staged LFS-tracked - **On commit**: a pre-commit hook (installed by `rad lfs init`) computes each staged
file to your local IPFS daemon, pins it, and writes the note above. 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 - **On push/pull**: the actual bytes move through
[`rad-lfs-transfer`][rad-lfs-transfer], a Git LFS custom transfer agent — see that repo for [`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 details. It's a separate binary/repository since it has nothing Radicle-specific in it (no
only needs a local IPFS daemon and a git repository with the notes above. 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 - **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 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 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. 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 [rad-lfs-transfer]: https://git.hswro.org/mab122/radicle-lfs-transfer
## Prerequisites ## 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 IPFS node, so seeding a repository keeps a full copy of its large files too, not just its git
history. 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 ### What happens without an IPFS daemon running
- `rad lfs init` checks upfront and refuses with a clear message - `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). | | `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). | | `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`. | | `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 ## Status

View File

@ -48,10 +48,13 @@
> ``` > ```
> >
> From here, use `rad` exactly as upstream describes below (`rad auth`, `rad init`, etc). The > 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 > only new commands are `rad lfs init` (run once per repository you want large-file support in)
> in — see [`LFS-IPFS.md`](LFS-IPFS.md) for that workflow. **Nothing above requires IPFS**; > and `rad lfs rekey` (run after granting a new collaborator access to a **private** repository,
> Git LFS support specifically needs a running `ipfs daemon`, and `rad lfs init` will tell you > so they can decrypt previously-committed LFS objects too) — see [`LFS-IPFS.md`](LFS-IPFS.md)
> plainly if one isn't reachable rather than failing confusingly later. > 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] [dependencies]
anyhow = "1" anyhow = "1"
base64 = { workspace = true }
chacha20poly1305 = { workspace = true }
chrono = { workspace = true, features = ["clock", "std"] } chrono = { workspace = true, features = ["clock", "std"] }
clap = { version = "4.5.44", features = ["derive"] } clap = { version = "4.5.44", features = ["derive"] }
clap_complete = "4.5" clap_complete = "4.5"
cyphernet = { workspace = true, features = ["ed25519"] }
dunce = { workspace = true } dunce = { workspace = true }
hkdf = { workspace = true }
human-panic.workspace = true human-panic.workspace = true
humantime.workspace = true humantime.workspace = true
itertools.workspace = true itertools.workspace = true
@ -31,7 +35,7 @@ log = { workspace = true, features = ["std"] }
nonempty = { workspace = true } nonempty = { workspace = true }
radicle = { workspace = true, features = ["schemars"] } radicle = { workspace = true, features = ["schemars"] }
radicle-cob = { workspace = true } radicle-cob = { workspace = true }
radicle-crypto = { workspace = true } radicle-crypto = { workspace = true, features = ["cyphernet"] }
radicle-localtime = { workspace = true } radicle-localtime = { workspace = true }
radicle-surf = { workspace = true } radicle-surf = { workspace = true }
radicle-term = { workspace = true } radicle-term = { workspace = true }
@ -39,9 +43,11 @@ radicle-log = { workspace = true }
schemars = { workspace = true } schemars = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
thiserror = { workspace = true, default-features = true } thiserror = { workspace = true, default-features = true }
timeago = { version = "0.4.2", default-features = false } timeago = { version = "0.4.2", default-features = false }
zeroize = { workspace = true }
tree-sitter = "0.24.4" tree-sitter = "0.24.4"
tree-sitter-bash = "0.23.3" tree-sitter-bash = "0.23.3"
tree-sitter-c = "0.23.2" tree-sitter-c = "0.23.2"
@ -56,7 +62,7 @@ tree-sitter-ruby = "0.23.1"
tree-sitter-rust = "0.23.2" tree-sitter-rust = "0.23.2"
tree-sitter-toml-ng = "0.6.0" tree-sitter-toml-ng = "0.6.0"
tree-sitter-typescript = "0.23.2" tree-sitter-typescript = "0.23.2"
ureq = { workspace = true } ureq = { workspace = true, features = ["multipart"] }
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
backtrace = { workspace = true, optional = true } backtrace = { workspace = true, optional = true }

View File

@ -1,6 +1,9 @@
//! `rad lfs` command implementation. //! `rad lfs` command implementation.
pub mod fetch;
pub mod init; pub mod init;
pub mod rekey;
pub mod store;
mod args; mod args;
@ -10,11 +13,10 @@ pub use args::Args;
use args::Command; use args::Command;
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> { 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 { match args.command {
Command::Init => self::init::run(), 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 { pub enum Command {
/// Set up Git LFS support for this repository, backed by IPFS /// Set up Git LFS support for this repository, backed by IPFS
Init, 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. /// For every file staged in the commit that's tracked by Git LFS (i.e.
/// matches a `filter=lfs` pattern in `.gitattributes`), this: /// matches a `filter=lfs` pattern in `.gitattributes`), this:
/// 1. `ipfs add`s the real file content to the local Kubo daemon, /// 1. computes the file's LFS oid (sha256) and size, and
/// 2. `ipfs pin add`s the resulting CID, /// 2. shells out to `rad lfs store` with those values, which does the
/// 3. computes the git blob hash of the LFS pointer text for that file, and /// actual IPFS add/pin (encrypting first for private repos) and writes
/// 4. writes a `cid=<cid>` git note on that blob hash under /// the corresponding note on `refs/notes/rad-lfs` itself.
/// `refs/notes/rad-lfs`.
/// ///
/// This mirrors `radicle-lfs-server`'s `notes::pointer_blob_hash` / /// The hook only computes the cheap, dependency-free oid/size values in
/// `notes::write_cid` logic (see that crate's `src/notes.rs`), reimplemented /// shell; the CID/encryption/git-notes logic lives in `rad lfs store`
/// here as a shell script since a git hook has no business depending on a /// (see `commands/lfs/store.rs`) since it needs access to the repo's
/// separate Rust crate. /// 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() { const HOOK_BODY: &str = r#"rad_lfs_precommit() {
command -v ipfs >/dev/null 2>&1 || { command -v ipfs >/dev/null 2>&1 || {
echo "rad-lfs: 'ipfs' CLI not found on PATH; skipping CID pinning for this commit" >&2 echo "rad-lfs: 'ipfs' CLI not found on PATH; skipping CID pinning for this commit" >&2
return 0 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() { sha256() {
if command -v sha256sum >/dev/null 2>&1; then 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; } oid=$(sha256 "$file") || { rm -f "$staged"; exit 1; }
size=$(wc -c < "$file" | tr -d ' ') size=$(wc -c < "$file" | tr -d ' ')
cid=$(ipfs add -Q -- "$file") || { rad lfs store --oid "$oid" --size "$size" -- "$file" >/dev/null || {
echo "rad-lfs: 'ipfs add' failed for $file" >&2 echo "rad-lfs: 'rad lfs store' 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
rm -f "$staged" rm -f "$staged"
exit 1 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 //! 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 //! 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 //! 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 //! is recorded as a git note on `refs/notes/rad-lfs` (note content is a
//! `cid=<cid>`). //! 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 //! Since seeding a repository already means "keep a full local copy of
//! it", we extend that lifecycle to IPFS: seeding a repository pins all //! 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 { let Some(message) = note.message() else {
continue; continue;
}; };
if let Some(cid) = message.trim().strip_prefix("cid=") { if let Ok(envelope) = serde_json::from_str::<crate::lfs_crypto::Envelope>(message) {
cids.push(cid.to_string()); cids.push(envelope.cid);
} }
} }
cids 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 /// Whether a `ureq` error indicates that the local IPFS daemon could
/// not be reached at all, as opposed to an HTTP-level error response. /// not be reached at all, as opposed to an HTTP-level error response.
fn is_daemon_unreachable(err: &ureq::Error) -> bool { 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 commands;
pub mod git; pub mod git;
pub mod ipfs; pub mod ipfs;
pub mod lfs_crypto;
pub mod node; pub mod node;
pub mod pager; pub mod pager;
pub mod project; pub mod project;

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