radicle_cli/commands/
auth.rs

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