From ff762ef8f2c045c1e7f1dca1e52f3a4483e13764 Mon Sep 17 00:00:00 2001 From: "Maciek \"mab122\" Bator" Date: Tue, 14 Jul 2026 17:59:04 +0200 Subject: [PATCH] Prompt interactively for passphrase instead of requiring RAD_PASSPHRASE Private-repo LFS operations need a signer capable of ECDH, which ssh-agent (the normal day-to-day flow after `rad auth`) can't provide -- that's a hard protocol limitation (standard ssh-agent only implements signing, there's no key-agreement request type), not a gap in our code, so there's no way to make it work "through the agent" the way signing does. Previously the only alternative to an unencrypted keystore was setting RAD_PASSPHRASE, which breaks the normal ssh-agent-based workflow and means putting a password in an environment variable -- not ergonomic, and not how any other `rad` command behaves. `rad` already has an established fallback for exactly this situation: `crate::terminal::io::signer` tries ssh-agent first, and if that's unavailable/unregistered and we're connected to a TTY, prompts interactively for the passphrase via `inquire` (reusing the existing `PassphraseValidator`). `lfs_crypto::load_signer` was missing this last step entirely -- it only tried "unencrypted keystore" then "RAD_PASSPHRASE" then gave up. Added the same interactive-prompt fallback, reusing the exact same validator/prompt helpers already used elsewhere, before falling back to a hard failure. This covers the common case (an interactive `git commit` triggering the pre-commit hook, which inherits the terminal's TTY) with no environment variable needed at all. Only genuinely non-interactive contexts (CI, a script with no TTY) still need RAD_PASSPHRASE -- and get a fast, clear failure rather than hanging, since `inquire` returns `Ok(None)` on `NotTTY` rather than blocking (verified: piping /dev/null as stdin still fails immediately, not a timeout). Verified against a real encrypted keystore through an actual pty (via `script`, since a plain non-interactive shell doesn't have a TTY to prompt on): the masked "Passphrase:" prompt appears, accepts the typed passphrase, and produces a correctly encrypted note -- with RAD_PASSPHRASE unset the entire time. --- LFS-IPFS.md | 40 +++++++++++++++++----------- crates/radicle-cli/src/lfs_crypto.rs | 39 ++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/LFS-IPFS.md b/LFS-IPFS.md index 1b4c1c66..756162ba 100644 --- a/LFS-IPFS.md +++ b/LFS-IPFS.md @@ -59,14 +59,24 @@ encrypts content before it ever reaches IPFS: - 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. +**This requires a signer capable of key agreement (ECDH), not just signing.** ssh-agent — the +normal day-to-day flow after `rad auth` — can sign but can't expose the key material ECDH +needs; that's a hard limitation of the standard ssh-agent protocol (it only implements +signing, there's no request type for general key agreement), not something to configure +around. `rad lfs store`/`rad lfs fetch`/`rad lfs rekey` on a *private* repository fall back to +whichever of these applies, in order: + +1. An unencrypted keystore, if that's how you've set things up. +2. `RAD_PASSPHRASE`, if set in the environment. +3. **An interactive passphrase prompt**, if connected to a terminal — the same fallback + `rad`'s other commands already use when ssh-agent isn't available or the key isn't + registered with it. This covers the common case (an interactive `git commit` triggering + the pre-commit hook, which inherits the terminal) with no env var needed at all. + +Only a genuinely non-interactive context (CI, a script with no TTY, `RAD_PASSPHRASE` unset) +hits a hard failure — and it fails fast with a clear message, rather than hanging waiting on a +prompt that can't be answered. Public repositories are entirely unaffected — no keys, no ECDH, +same plaintext behavior as before. ### Granting access to existing objects: `rad lfs rekey` @@ -184,12 +194,12 @@ git lfs pull # fetches large files via IPFS instead of git IPFS node, so seeding a repository keeps a full copy of its large files too, not just its git history. -**On a private repository**, make sure `RAD_PASSPHRASE` is set (or your keystore is -unencrypted) before committing — see [Encryption for private repositories](#encryption-for-private-repositories) -above. After granting a new collaborator access (`rad id update --allow ` or adding them -as a delegate), have an already-authorized collaborator run `rad lfs rekey` and push, so the -new collaborator can decrypt previously-committed LFS objects too, not just ones committed -after they were added. +**On a private repository**, committing from a terminal you'll be prompted for your passphrase +if needed (see [Encryption for private repositories](#encryption-for-private-repositories) +above) — no extra setup required. After granting a new collaborator access +(`rad id update --allow ` or adding them as a delegate), have an already-authorized +collaborator run `rad lfs rekey` and push, so the new collaborator can decrypt +previously-committed LFS objects too, not just ones committed after they were added. ### What happens without an IPFS daemon running @@ -211,7 +221,7 @@ after they were added. | `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`/`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. | +| `private-repo LFS operations need to perform a key-agreement (ECDH) operation ...` | Your keystore is encrypted, ssh-agent can't do key agreement (protocol limitation), and no passphrase is available — normally you'd just be prompted interactively; this only happens in a non-interactive context (no TTY, e.g. CI). Set `RAD_PASSPHRASE`, or use an unencrypted keystore, or run it from a terminal. | | `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. | | `fatal: couldn't find remote ref refs/notes/rad-lfs` on `git fetch`/`git pull` (breaks *all* fetching, not just LFS) | Fixed — but if this repo had `rad lfs init` run against an older build, re-run `rad lfs init` once to replace the stale, now-invalid fetch refspec it configured (see `NOTES-lfs-notes-fetch-bug.md` for the full story). | | Plain `git push rad` (no branch name) doesn't push your commits, only the notes ref | Known, separate quirk: once any explicit push refspec is configured (which `rad lfs init` does), git stops falling back to pushing the current branch by default. Always push explicitly: `git push rad `. | diff --git a/crates/radicle-cli/src/lfs_crypto.rs b/crates/radicle-cli/src/lfs_crypto.rs index 4f3b1f27..60bd9cac 100644 --- a/crates/radicle-cli/src/lfs_crypto.rs +++ b/crates/radicle-cli/src/lfs_crypto.rs @@ -102,10 +102,24 @@ pub fn recipients_for(doc: &Doc, sender: Did) -> BTreeSet { } /// 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. +/// `Ecdh` trait -- ssh-agent (the normal day-to-day flow after `rad +/// auth`) can sign but can't expose the raw key material a key-agreement +/// operation needs. This is a hard limitation of the standard ssh-agent +/// protocol, which only implements signing operations, not general key +/// agreement -- there's no ssh-agent request type for it, so this can't +/// be routed through the agent the way signing is. +/// +/// The next best thing, matching the exact fallback +/// `crate::terminal::io::signer` already uses elsewhere in this CLI when +/// ssh-agent isn't available or the key isn't registered with it: if +/// connected to a TTY, prompt interactively for the passphrase instead of +/// requiring `RAD_PASSPHRASE` to be pre-set. This covers the common case +/// (an interactive `git commit` triggering the pre-commit hook, which +/// inherits the terminal's TTY) without needing an env var at all; only +/// genuinely non-interactive contexts (CI, a script with no TTY) still +/// need `RAD_PASSPHRASE`, and get a fast, clear failure rather than a +/// hang, since `inquire` (via `radicle_term::io::passphrase`) returns +/// `Ok(None)` on `NotTTY` rather than blocking. pub fn load_signer(profile: &Profile) -> anyhow::Result { if !profile.keystore.is_encrypted()? { return Ok(MemorySigner::load(&profile.keystore, None)?); @@ -113,13 +127,26 @@ pub fn load_signer(profile: &Profile) -> anyhow::Result { if let Some(passphrase) = radicle::profile::env::passphrase() { return Ok(MemorySigner::load(&profile.keystore, Some(passphrase))?); } + if let Some(passphrase) = prompt_passphrase(profile)? { + 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." + requires direct access to your secret key -- ssh-agent can sign but can't do this \ + (a protocol limitation, not something to configure around). Run this from a \ + terminal to be prompted for your passphrase, or set RAD_PASSPHRASE, or use an \ + unencrypted keystore." ) } +/// Prompts for the keystore passphrase if connected to a TTY. Returns +/// `Ok(None)` (not an error) when there's no TTY to prompt on, so callers +/// can fall through to a clear error instead of hanging. +fn prompt_passphrase(profile: &Profile) -> anyhow::Result> { + let validator = crate::terminal::io::PassphraseValidator::new(profile.keystore.clone()); + Ok(crate::terminal::io::passphrase(validator)?) +} + /// 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.