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:
parent
23264b9b52
commit
e060442017
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
86
LFS-IPFS.md
86
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=<cid>` git note on `refs/notes/rad-lfs`, attached to each LFS
|
||||
pointer file's own git blob hash (not the LFS oid — a different value). Verified against
|
||||
this repo's actual replication code (`references_of` in
|
||||
`crates/radicle/src/storage/git.rs`) that Radicle replicates every ref under a peer's
|
||||
namespace except `refs/tmp/heads/*` — there's no fixed allowlist, so this notes ref
|
||||
replicates the same way `refs/heads`/`refs/tags`/`refs/cobs` do, once the push/fetch
|
||||
refspecs below are configured.
|
||||
- **On commit**: a pre-commit hook (installed by `rad lfs init`) adds each staged LFS-tracked
|
||||
file to your local IPFS daemon, pins it, and writes the note above.
|
||||
- **The mapping**: a JSON git note on `refs/notes/rad-lfs`, attached to each LFS pointer
|
||||
file's own git blob hash (not the LFS oid — a different value), e.g. `{"v":1,"cid":"Qm..."}`
|
||||
for a public-repo object. Verified against this repo's actual replication code
|
||||
(`references_of` in `crates/radicle/src/storage/git.rs`) that Radicle replicates every ref
|
||||
under a peer's namespace except `refs/tmp/heads/*` — there's no fixed allowlist, so this
|
||||
notes ref replicates the same way `refs/heads`/`refs/tags`/`refs/cobs` do, once the
|
||||
push/fetch refspecs below are configured.
|
||||
- **On commit**: a pre-commit hook (installed by `rad lfs init`) computes each staged
|
||||
LFS-tracked file's oid/size and shells out to `rad lfs store`, which adds it to your local
|
||||
IPFS daemon, pins it, and writes the note above — encrypting the content first if the
|
||||
repository is private (see below).
|
||||
- **On push/pull**: the actual bytes move through
|
||||
[`rad-lfs-transfer`][rad-lfs-transfer], a Git LFS custom transfer agent — see that repo for
|
||||
details. It's a separate binary/repository since it has nothing Radicle-specific in it; it
|
||||
only needs a local IPFS daemon and a git repository with the notes above.
|
||||
details. It's a separate binary/repository since it has nothing Radicle-specific in it (no
|
||||
identity, no keys, no encryption); it shells out to `rad lfs store`/`rad lfs fetch` for the
|
||||
actual work.
|
||||
- **Seeding**: `rad seed` now also pins a repository's known LFS objects in your local IPFS
|
||||
node (mirroring "seeding = keep a full copy"); `rad unseed` unpins them. Known prototype
|
||||
limitation: no cross-repository pin refcounting, so unseeding one repository can unpin
|
||||
content still needed by another seeded repository that happens to share the same CID.
|
||||
|
||||
## Encryption for private repositories
|
||||
|
||||
Radicle's `Visibility::Private` is enforced entirely by access control at the replication
|
||||
layer — git objects for a private repo are plain, unencrypted objects, protected only by
|
||||
"unauthorized peers are never given them" (see `crates/radicle/src/identity/doc.rs`). That
|
||||
protection has no jurisdiction over IPFS's own block-exchange layer: once a legitimate
|
||||
collaborator's IPFS daemon pins content and joins the network, anyone who obtains the CID by
|
||||
any means can fetch the plaintext directly. So for private repositories, `rad lfs store`
|
||||
encrypts content before it ever reaches IPFS:
|
||||
|
||||
- A random per-object content key (CEK) encrypts the file (XChaCha20-Poly1305).
|
||||
- The CEK is wrapped once per authorized recipient (repo delegates + the private allow-list),
|
||||
using ECDH between the committer's and each recipient's Radicle identity key (Ed25519,
|
||||
directly on the curve — no separate encryption keypair to manage) plus HKDF, so only
|
||||
identities Radicle already considers authorized for the repo can decrypt.
|
||||
- The wrapped keys travel in the same git note as the CID — no separate key-distribution
|
||||
mechanism, no server.
|
||||
|
||||
**This requires a signer capable of key agreement (ECDH), not just signing.** An ssh-agent-only
|
||||
signer can sign but can't expose the key material ECDH needs. Concretely: `rad lfs store`/
|
||||
`rad lfs fetch`/`rad lfs rekey` on a *private* repository require either an unencrypted
|
||||
keystore, or `RAD_PASSPHRASE` set in the environment — including inside the pre-commit hook at
|
||||
commit time. If neither is available, these commands fail immediately with a clear message
|
||||
telling you to set `RAD_PASSPHRASE`, rather than hanging on a passphrase prompt inside a
|
||||
non-interactive hook. Public repositories are entirely unaffected — no keys, no ECDH, same
|
||||
plaintext behavior as before.
|
||||
|
||||
### Granting access to existing objects: `rad lfs rekey`
|
||||
|
||||
Radicle's access control is retroactive: once a DID is authorized, it can replicate a
|
||||
repository's entire history, including everything from before it was added. Without extra
|
||||
handling, this encryption design would *not* match that: a newly-authorized collaborator could
|
||||
see a private repo's full git history but be permanently unable to decrypt any LFS object
|
||||
committed before they were granted access, since those objects' wrapped-key lists only include
|
||||
whoever was authorized at the time.
|
||||
|
||||
`rad lfs rekey` closes that gap. Run by anyone *already* authorized (it can't be self-served by
|
||||
the new collaborator — they have nothing to decrypt yet), it walks every encrypted object,
|
||||
recovers each one's content key using the runner's own existing access, and adds a wrapped-key
|
||||
entry for any currently-authorized DID that's missing one. It never re-encrypts content or
|
||||
touches IPFS — it's a pure git-notes operation, so it's fast and doesn't need the original
|
||||
file. Run it after adding a new delegate or allow-listing a new DID on a private repository, if
|
||||
you want them to have access to previously-committed LFS objects (not automatic — see
|
||||
[Prerequisites](#prerequisites)/[Use](#use) below for the full flow).
|
||||
|
||||
**Still explicitly not solved**: revocation. A formerly-authorized peer who already decrypted
|
||||
an object can't be made to un-know its content key — the same fundamental limitation as
|
||||
Radicle's own replication-based access control (already-replicated git objects can't be
|
||||
un-seen either), not a regression introduced by this design.
|
||||
|
||||
[rad-lfs-transfer]: https://git.hswro.org/mab122/radicle-lfs-transfer
|
||||
|
||||
## Prerequisites
|
||||
|
|
@ -131,6 +184,13 @@ git lfs pull # fetches large files via IPFS instead of git
|
|||
IPFS node, so seeding a repository keeps a full copy of its large files too, not just its git
|
||||
history.
|
||||
|
||||
**On a private repository**, make sure `RAD_PASSPHRASE` is set (or your keystore is
|
||||
unencrypted) before committing — see [Encryption for private repositories](#encryption-for-private-repositories)
|
||||
above. After granting a new collaborator access (`rad id update --allow <did>` or adding them
|
||||
as a delegate), have an already-authorized collaborator run `rad lfs rekey` and push, so the
|
||||
new collaborator can decrypt previously-committed LFS objects too, not just ones committed
|
||||
after they were added.
|
||||
|
||||
### What happens without an IPFS daemon running
|
||||
|
||||
- `rad lfs init` checks upfront and refuses with a clear message
|
||||
|
|
@ -150,7 +210,9 @@ history.
|
|||
| `Git LFS is not installed (the 'git-lfs' binary was not found on your PATH)` | Install [Git LFS](https://git-lfs.com). |
|
||||
| `no IPFS (Kubo) daemon reachable at ...` | Start one: `ipfs daemon` (see [Run](#run) above). |
|
||||
| `rad-lfs-transfer: command not found` during `git lfs push`/`pull` | `cargo install --path radicle-lfs-transfer` didn't complete, or `~/.radicle/bin` isn't on `PATH`. |
|
||||
| Large file didn't get pinned (commit went through, but no `rad-lfs` note) | The pre-commit hook silently skips pinning if the `ipfs` CLI isn't on `PATH` — check `command -v ipfs`. Re-add the file and commit again once it is. |
|
||||
| Large file didn't get pinned (commit went through, but no `rad-lfs` note) | The pre-commit hook silently skips pinning if the `ipfs`/`rad` CLIs aren't on `PATH` — check `command -v ipfs` / `command -v rad`. Re-add the file and commit again once available. |
|
||||
| `private-repo LFS operations need to perform a key-agreement (ECDH) operation ... Set RAD_PASSPHRASE` | Your keystore is encrypted and no passphrase is available (an ssh-agent-only signer can't do key agreement). Set `RAD_PASSPHRASE` in the environment the commit/fetch runs in, or use an unencrypted keystore. |
|
||||
| `you are not an authorized recipient of this object` | Either genuinely unauthorized (not a delegate/allow-listed DID for this private repo), or authorized *after* this specific object was committed and nobody has run `rad lfs rekey` since — ask an already-authorized collaborator to run it and push. |
|
||||
|
||||
## Status
|
||||
|
||||
|
|
|
|||
11
README.md
11
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -42,21 +42,28 @@ const HOOK_END: &str = "# <<< rad-lfs-init managed pre-commit hook <<<";
|
|||
///
|
||||
/// For every file staged in the commit that's tracked by Git LFS (i.e.
|
||||
/// matches a `filter=lfs` pattern in `.gitattributes`), this:
|
||||
/// 1. `ipfs add`s the real file content to the local Kubo daemon,
|
||||
/// 2. `ipfs pin add`s the resulting CID,
|
||||
/// 3. computes the git blob hash of the LFS pointer text for that file, and
|
||||
/// 4. writes a `cid=<cid>` git note on that blob hash under
|
||||
/// `refs/notes/rad-lfs`.
|
||||
/// 1. computes the file's LFS oid (sha256) and size, and
|
||||
/// 2. shells out to `rad lfs store` with those values, which does the
|
||||
/// actual IPFS add/pin (encrypting first for private repos) and writes
|
||||
/// the corresponding note on `refs/notes/rad-lfs` itself.
|
||||
///
|
||||
/// This mirrors `radicle-lfs-server`'s `notes::pointer_blob_hash` /
|
||||
/// `notes::write_cid` logic (see that crate's `src/notes.rs`), reimplemented
|
||||
/// here as a shell script since a git hook has no business depending on a
|
||||
/// separate Rust crate.
|
||||
/// The hook only computes the cheap, dependency-free oid/size values in
|
||||
/// shell; the CID/encryption/git-notes logic lives in `rad lfs store`
|
||||
/// (see `commands/lfs/store.rs`) since it needs access to the repo's
|
||||
/// identity/visibility and (for private repos) key material that a plain
|
||||
/// shell script has no business handling — a git hook shelling out to our
|
||||
/// own `rad` binary is far simpler than reimplementing that here.
|
||||
///
|
||||
/// Requires both `ipfs` and `rad` to be available on `PATH` at commit time.
|
||||
const HOOK_BODY: &str = r#"rad_lfs_precommit() {
|
||||
command -v ipfs >/dev/null 2>&1 || {
|
||||
echo "rad-lfs: 'ipfs' CLI not found on PATH; skipping CID pinning for this commit" >&2
|
||||
return 0
|
||||
}
|
||||
command -v rad >/dev/null 2>&1 || {
|
||||
echo "rad-lfs: 'rad' CLI not found on PATH; skipping CID pinning for this commit" >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
|
|
@ -79,24 +86,8 @@ const HOOK_BODY: &str = r#"rad_lfs_precommit() {
|
|||
oid=$(sha256 "$file") || { rm -f "$staged"; exit 1; }
|
||||
size=$(wc -c < "$file" | tr -d ' ')
|
||||
|
||||
cid=$(ipfs add -Q -- "$file") || {
|
||||
echo "rad-lfs: 'ipfs add' failed for $file" >&2
|
||||
rm -f "$staged"
|
||||
exit 1
|
||||
}
|
||||
ipfs pin add -- "$cid" >/dev/null || {
|
||||
echo "rad-lfs: 'ipfs pin add' failed for $cid" >&2
|
||||
rm -f "$staged"
|
||||
exit 1
|
||||
}
|
||||
|
||||
blob_sha=$(printf 'version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %s\n' "$oid" "$size" | git hash-object --stdin -t blob) || {
|
||||
rm -f "$staged"
|
||||
exit 1
|
||||
}
|
||||
|
||||
git notes --ref=refs/notes/rad-lfs add -f -m "cid=$cid" "$blob_sha" >/dev/null || {
|
||||
echo "rad-lfs: failed to write git note for $file" >&2
|
||||
rad lfs store --oid "$oid" --size "$size" -- "$file" >/dev/null || {
|
||||
echo "rad-lfs: 'rad lfs store' failed for $file" >&2
|
||||
rm -f "$staged"
|
||||
exit 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@
|
|||
//! This is a prototype of "Git-LFS over IPFS": large files tracked via
|
||||
//! Git LFS are stored in IPFS rather than as git blobs, and the mapping
|
||||
//! from an LFS pointer file's git blob oid to the corresponding IPFS CID
|
||||
//! is recorded as a git note on `refs/notes/rad-lfs` (note content
|
||||
//! `cid=<cid>`).
|
||||
//! is recorded as a git note on `refs/notes/rad-lfs` (note content is a
|
||||
//! JSON [`crate::lfs_crypto::Envelope`], e.g. `{"v":1,"cid":"Qm...",
|
||||
//! "enc":null}` for a public repo, or with populated `enc` metadata for
|
||||
//! a private one).
|
||||
//!
|
||||
//! Since seeding a repository already means "keep a full local copy of
|
||||
//! it", we extend that lifecycle to IPFS: seeding a repository pins all
|
||||
|
|
@ -70,13 +72,77 @@ pub fn lfs_cids(repo: &Repository) -> Vec<String> {
|
|||
let Some(message) = note.message() else {
|
||||
continue;
|
||||
};
|
||||
if let Some(cid) = message.trim().strip_prefix("cid=") {
|
||||
cids.push(cid.to_string());
|
||||
if let Ok(envelope) = serde_json::from_str::<crate::lfs_crypto::Envelope>(message) {
|
||||
cids.push(envelope.cid);
|
||||
}
|
||||
}
|
||||
cids
|
||||
}
|
||||
|
||||
/// Add `bytes` to the local IPFS node as a single blob, returning its CID.
|
||||
pub fn add(bytes: &[u8]) -> anyhow::Result<String> {
|
||||
use ureq::unversioned::multipart::{Form, Part};
|
||||
|
||||
let base = kubo_api_url();
|
||||
let url = format!("{base}/api/v0/add");
|
||||
|
||||
let form = Form::new().part("file", Part::bytes(bytes).file_name("blob"));
|
||||
|
||||
let mut response = ureq::post(&url).send(form).map_err(|err| {
|
||||
if is_daemon_unreachable(&err) {
|
||||
anyhow!(
|
||||
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
|
||||
This repo's large-file (LFS) support is backed by your local IPFS node."
|
||||
)
|
||||
} else {
|
||||
anyhow!("failed to add object to IPFS: {err}")
|
||||
}
|
||||
})?;
|
||||
|
||||
let body = response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.map_err(|err| anyhow!("failed to read IPFS `add` response: {err}"))?;
|
||||
// The response is newline-delimited JSON, one object per added file;
|
||||
// we only ever add a single blob per call, so the first line is all
|
||||
// we need.
|
||||
let line = body
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("empty response from IPFS `add`"))?;
|
||||
let value: serde_json::Value = serde_json::from_str(line)
|
||||
.map_err(|err| anyhow!("failed to parse IPFS `add` response: {err}"))?;
|
||||
let hash = value
|
||||
.get("Hash")
|
||||
.and_then(|h| h.as_str())
|
||||
.ok_or_else(|| anyhow!("IPFS `add` response missing a `Hash` field"))?;
|
||||
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Fetch the raw bytes of the object identified by `cid` from the local
|
||||
/// IPFS node.
|
||||
pub fn cat(cid: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let base = kubo_api_url();
|
||||
let url = format!("{base}/api/v0/cat?arg={cid}");
|
||||
|
||||
let mut response = ureq::post(&url).send_empty().map_err(|err| {
|
||||
if is_daemon_unreachable(&err) {
|
||||
anyhow!(
|
||||
"no IPFS (Kubo) daemon reachable at {base} — start one with `ipfs daemon`.\n\
|
||||
This repo's large-file (LFS) support is backed by your local IPFS node."
|
||||
)
|
||||
} else {
|
||||
anyhow!("failed to fetch object {cid} from IPFS: {err}")
|
||||
}
|
||||
})?;
|
||||
|
||||
response
|
||||
.body_mut()
|
||||
.read_to_vec()
|
||||
.map_err(|err| anyhow!("failed to read IPFS `cat` response for {cid}: {err}"))
|
||||
}
|
||||
|
||||
/// Whether a `ureq` error indicates that the local IPFS daemon could
|
||||
/// not be reached at all, as opposed to an HTTP-level error response.
|
||||
fn is_daemon_unreachable(err: &ureq::Error) -> bool {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
pub mod commands;
|
||||
pub mod git;
|
||||
pub mod ipfs;
|
||||
pub mod lfs_crypto;
|
||||
pub mod node;
|
||||
pub mod pager;
|
||||
pub mod project;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit fb95fd0c68eb260b671839f74235d03b252b19d7
|
||||
Subproject commit d67ac79f4096622dfa16fad05fd0694e0c047a36
|
||||
Loading…
Reference in New Issue