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.
This commit is contained in:
parent
1c65ee6230
commit
ff762ef8f2
40
LFS-IPFS.md
40
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
|
- The wrapped keys travel in the same git note as the CID — no separate key-distribution
|
||||||
mechanism, no server.
|
mechanism, no server.
|
||||||
|
|
||||||
**This requires a signer capable of key agreement (ECDH), not just signing.** An ssh-agent-only
|
**This requires a signer capable of key agreement (ECDH), not just signing.** ssh-agent — the
|
||||||
signer can sign but can't expose the key material ECDH needs. Concretely: `rad lfs store`/
|
normal day-to-day flow after `rad auth` — can sign but can't expose the key material ECDH
|
||||||
`rad lfs fetch`/`rad lfs rekey` on a *private* repository require either an unencrypted
|
needs; that's a hard limitation of the standard ssh-agent protocol (it only implements
|
||||||
keystore, or `RAD_PASSPHRASE` set in the environment — including inside the pre-commit hook at
|
signing, there's no request type for general key agreement), not something to configure
|
||||||
commit time. If neither is available, these commands fail immediately with a clear message
|
around. `rad lfs store`/`rad lfs fetch`/`rad lfs rekey` on a *private* repository fall back to
|
||||||
telling you to set `RAD_PASSPHRASE`, rather than hanging on a passphrase prompt inside a
|
whichever of these applies, in order:
|
||||||
non-interactive hook. Public repositories are entirely unaffected — no keys, no ECDH, same
|
|
||||||
plaintext behavior as before.
|
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`
|
### 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
|
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
|
**On a private repository**, committing from a terminal you'll be prompted for your passphrase
|
||||||
unencrypted) before committing — see [Encryption for private repositories](#encryption-for-private-repositories)
|
if needed (see [Encryption for private repositories](#encryption-for-private-repositories)
|
||||||
above. After granting a new collaborator access (`rad id update --allow <did>` or adding them
|
above) — no extra setup required. After granting a new collaborator access
|
||||||
as a delegate), have an already-authorized collaborator run `rad lfs rekey` and push, so the
|
(`rad id update --allow <did>` or adding them as a delegate), have an already-authorized
|
||||||
new collaborator can decrypt previously-committed LFS objects too, not just ones committed
|
collaborator run `rad lfs rekey` and push, so the new collaborator can decrypt
|
||||||
after they were added.
|
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
|
||||||
|
|
||||||
|
|
@ -211,7 +221,7 @@ after they were added.
|
||||||
| `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`/`rad` CLIs aren't on `PATH` — check `command -v ipfs` / `command -v rad`. Re-add the file and commit again once available. |
|
| 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. |
|
| `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). |
|
| `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 <branch>`. |
|
| 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 <branch>`. |
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,24 @@ pub fn recipients_for(doc: &Doc, sender: Did) -> BTreeSet<Did> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads a signer capable of ECDH. Only `MemorySigner` implements the
|
/// Loads a signer capable of ECDH. Only `MemorySigner` implements the
|
||||||
/// `Ecdh` trait -- an ssh-agent-backed signer can sign but can't expose
|
/// `Ecdh` trait -- ssh-agent (the normal day-to-day flow after `rad
|
||||||
/// the raw key material a key-agreement operation needs. Fails fast with
|
/// auth`) can sign but can't expose the raw key material a key-agreement
|
||||||
/// an actionable message instead of hanging on a passphrase prompt, which
|
/// operation needs. This is a hard limitation of the standard ssh-agent
|
||||||
/// matters since this may run inside a non-interactive git hook.
|
/// 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<MemorySigner> {
|
pub fn load_signer(profile: &Profile) -> anyhow::Result<MemorySigner> {
|
||||||
if !profile.keystore.is_encrypted()? {
|
if !profile.keystore.is_encrypted()? {
|
||||||
return Ok(MemorySigner::load(&profile.keystore, None)?);
|
return Ok(MemorySigner::load(&profile.keystore, None)?);
|
||||||
|
|
@ -113,13 +127,26 @@ pub fn load_signer(profile: &Profile) -> anyhow::Result<MemorySigner> {
|
||||||
if let Some(passphrase) = radicle::profile::env::passphrase() {
|
if let Some(passphrase) = radicle::profile::env::passphrase() {
|
||||||
return Ok(MemorySigner::load(&profile.keystore, Some(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!(
|
bail!(
|
||||||
"private-repo LFS operations need to perform a key-agreement (ECDH) operation, which \
|
"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. \
|
requires direct access to your secret key -- ssh-agent can sign but can't do this \
|
||||||
Set RAD_PASSPHRASE (or use an unencrypted keystore) and try again."
|
(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<Option<radicle_crypto::ssh::keystore::Passphrase>> {
|
||||||
|
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
|
/// Encrypts `plaintext` with a fresh content key, then wraps that key once
|
||||||
/// per recipient. Returns the ciphertext to store in IPFS and the
|
/// per recipient. Returns the ciphertext to store in IPFS and the
|
||||||
/// encryption metadata to record in the note.
|
/// encryption metadata to record in the note.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue