radicle_cli/commands/
auth.rs

1#![allow(clippy::or_fun_call)]
2use std::ffi::OsString;
3use std::str::FromStr;
4
5use anyhow::{anyhow, Context};
6
7use radicle::crypto::ssh;
8use radicle::crypto::ssh::Passphrase;
9use radicle::node::Alias;
10use radicle::profile::env;
11use radicle::{profile, Profile};
12
13use crate::terminal as term;
14use crate::terminal::args::{Args, Error, Help};
15
16pub const HELP: Help = Help {
17    name: "auth",
18    description: "Manage identities and profiles",
19    version: env!("RADICLE_VERSION"),
20    usage: r#"
21Usage
22
23    rad auth [<option>...]
24
25    A passphrase may be given via the environment variable `RAD_PASSPHRASE` or
26    via the standard input stream if `--stdin` is used. Using either of these
27    methods disables the passphrase prompt.
28
29Options
30
31    --alias                 When initializing an identity, sets the node alias
32    --stdin                 Read passphrase from stdin (default: false)
33    --help                  Print help
34"#,
35};
36
37#[derive(Debug)]
38pub struct Options {
39    pub stdin: bool,
40    pub alias: Option<Alias>,
41}
42
43impl Args for Options {
44    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
45        use lexopt::prelude::*;
46
47        let mut stdin = false;
48        let mut alias = None;
49        let mut parser = lexopt::Parser::from_args(args);
50
51        while let Some(arg) = parser.next()? {
52            match arg {
53                Long("alias") => {
54                    let val = parser.value()?;
55                    let val = term::args::alias(&val)?;
56
57                    alias = Some(val);
58                }
59                Long("stdin") => {
60                    stdin = true;
61                }
62                Long("help") | Short('h') => {
63                    return Err(Error::Help.into());
64                }
65                _ => anyhow::bail!(arg.unexpected()),
66            }
67        }
68
69        Ok((Options { alias, stdin }, vec![]))
70    }
71}
72
73pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
74    match ctx.profile() {
75        Ok(profile) => authenticate(options, &profile),
76        Err(_) => init(options),
77    }
78}
79
80pub fn init(options: Options) -> anyhow::Result<()> {
81    term::headline("Initializing your radicle 👾 identity");
82
83    if let Ok(version) = radicle::git::version() {
84        if version < radicle::git::VERSION_REQUIRED {
85            term::warning(format!(
86                "Your Git version is unsupported, please upgrade to {} or later",
87                radicle::git::VERSION_REQUIRED,
88            ));
89            term::blank();
90        }
91    } else {
92        anyhow::bail!("A Git installation is required for Radicle to run.");
93    }
94
95    let alias: Alias = if let Some(alias) = options.alias {
96        alias
97    } else {
98        let user = env::var("USER").ok().and_then(|u| Alias::from_str(&u).ok());
99        let user = term::input(
100            "Enter your alias:",
101            user,
102            Some("This is your node alias. You can always change it later"),
103        )?;
104
105        user.ok_or_else(|| anyhow::anyhow!("An alias is required for Radicle to run."))?
106    };
107    let home = profile::home()?;
108    let passphrase = if options.stdin {
109        Some(term::passphrase_stdin()?)
110    } else {
111        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)?
112    };
113    let passphrase = passphrase.filter(|passphrase| !passphrase.trim().is_empty());
114    let spinner = term::spinner("Creating your Ed25519 keypair...");
115    let profile = Profile::init(home, alias, passphrase.clone(), env::seed())?;
116    let mut agent = true;
117    spinner.finish();
118
119    if let Some(passphrase) = passphrase {
120        match ssh::agent::Agent::connect() {
121            Ok(mut agent) => {
122                let mut spinner = term::spinner("Adding your radicle key to ssh-agent...");
123                if register(&mut agent, &profile, passphrase).is_ok() {
124                    spinner.finish();
125                } else {
126                    spinner.message("Could not register radicle key in ssh-agent.");
127                    spinner.warn();
128                }
129            }
130            Err(e) if e.is_not_running() => {
131                agent = false;
132            }
133            Err(e) => Err(e).context("failed to connect to ssh-agent")?,
134        }
135    }
136
137    term::success!(
138        "Your Radicle DID is {}. This identifies your device. Run {} to show it at all times.",
139        term::format::highlight(profile.did()),
140        term::format::command("rad self")
141    );
142    term::success!("You're all set.");
143    term::blank();
144
145    if profile.config.cli.hints && !agent {
146        term::hint("install ssh-agent to have it fill in your passphrase for you when signing.");
147        term::blank();
148    }
149    term::info!(
150        "To create a Radicle repository, run {} from a Git repository with at least one commit.",
151        term::format::command("rad init")
152    );
153    term::info!(
154        "To clone a repository, run {}. For example, {} clones the Radicle 'heartwood' repository.",
155        term::format::command("rad clone <rid>"),
156        term::format::command("rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5")
157    );
158    term::info!(
159        "To get a list of all commands, run {}.",
160        term::format::command("rad"),
161    );
162
163    Ok(())
164}
165
166/// Try loading the identity's key into SSH Agent, falling back to verifying `RAD_PASSPHRASE` for
167/// use.
168pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {
169    if !profile.keystore.is_encrypted()? {
170        term::success!("Authenticated as {}", term::format::tertiary(profile.id()));
171        return Ok(());
172    }
173    for (key, _) in &profile.config.node.extra {
174        term::warning(format!(
175            "unused or deprecated configuration attribute {key:?}"
176        ));
177    }
178
179    // If our key is encrypted, we try to authenticate with SSH Agent and
180    // register it; only if it is running.
181    match ssh::agent::Agent::connect() {
182        Ok(mut agent) => {
183            if agent.request_identities()?.contains(&profile.public_key) {
184                term::success!("Radicle key already in ssh-agent");
185                return Ok(());
186            }
187            let passphrase = if let Some(phrase) = profile::env::passphrase() {
188                phrase
189            } else if options.stdin {
190                term::passphrase_stdin()?
191            } else if let Some(passphrase) =
192                term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
193            {
194                passphrase
195            } else {
196                anyhow::bail!(
197                    "A passphrase is required to read your Radicle key. Unable to continue."
198                )
199            };
200            register(&mut agent, profile, passphrase)?;
201
202            term::success!("Radicle key added to {}", term::format::dim("ssh-agent"));
203
204            return Ok(());
205        }
206        Err(e) if e.is_not_running() => {}
207        Err(e) => Err(e)?,
208    };
209
210    // Try RAD_PASSPHRASE fallback.
211    if let Some(passphrase) = profile::env::passphrase() {
212        ssh::keystore::MemorySigner::load(&profile.keystore, Some(passphrase))
213            .map_err(|_| anyhow!("`{}` is invalid", env::RAD_PASSPHRASE))?;
214        return Ok(());
215    }
216
217    term::print(term::format::dim(
218        "Nothing to do, ssh-agent is not running.",
219    ));
220    term::print(term::format::dim(
221        "You will be prompted for a passphrase when necessary.",
222    ));
223
224    Ok(())
225}
226
227/// Register key with ssh-agent.
228pub fn register(
229    agent: &mut ssh::agent::Agent,
230    profile: &Profile,
231    passphrase: Passphrase,
232) -> anyhow::Result<()> {
233    let secret = profile
234        .keystore
235        .secret_key(Some(passphrase))
236        .map_err(|e| {
237            if e.is_crypto_err() {
238                anyhow!("could not decrypt secret key: invalid passphrase")
239            } else {
240                e.into()
241            }
242        })?
243        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.secret_key_path()))?;
244
245    agent.register(&secret)?;
246
247    Ok(())
248}