Skip to main content

radicle_cli/commands/
auth.rs

1#![allow(clippy::or_fun_call)]
2mod args;
3
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;
15
16pub use args::Args;
17
18pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
19    match ctx.profile() {
20        Ok(profile) => authenticate(args, &profile),
21        Err(_) => init(args),
22    }
23}
24
25pub fn init(args: Args) -> anyhow::Result<()> {
26    term::headline("Initializing your radicle 👾 identity");
27
28    if let Ok(version) = radicle::git::version() {
29        if version < radicle::git::VERSION_REQUIRED {
30            term::warning(format!(
31                "Your Git version is unsupported, please upgrade to {} or later",
32                radicle::git::VERSION_REQUIRED,
33            ));
34            term::blank();
35        }
36    } else {
37        anyhow::bail!("A Git installation is required for Radicle to run.");
38    }
39
40    let alias: Alias = if let Some(alias) = args.alias {
41        alias
42    } else {
43        let user = env::var("USER").ok().and_then(|u| Alias::from_str(&u).ok());
44        let user = term::input(
45            "Enter your alias:",
46            user,
47            Some("This is your node alias. You can always change it later"),
48        )?;
49
50        user.ok_or_else(|| anyhow::anyhow!("An alias is required for Radicle to run."))?
51    };
52    let home = profile::home()?;
53    let passphrase = if args.stdin {
54        Some(term::passphrase_stdin()?)
55    } else {
56        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)?
57    };
58    let passphrase = passphrase.filter(|passphrase| !passphrase.trim().is_empty());
59    let spinner = term::spinner("Creating your Ed25519 keypair...");
60    let profile = Profile::init(home, alias, passphrase.clone(), env::seed())?;
61    let mut agent = true;
62    spinner.finish();
63
64    if let Some(passphrase) = passphrase {
65        match ssh::agent::Agent::connect() {
66            Ok(mut agent) => {
67                let mut spinner = term::spinner("Adding your radicle key to ssh-agent...");
68                if register(&mut agent, &profile, passphrase).is_ok() {
69                    spinner.finish();
70                } else {
71                    spinner.message("Could not register radicle key in ssh-agent.");
72                    spinner.warn();
73                }
74            }
75            Err(e) if e.is_not_running() => {
76                agent = false;
77            }
78            Err(e) => Err(e).context("failed to connect to ssh-agent")?,
79        }
80    }
81
82    term::success!(
83        "Your Radicle DID is {}. This identifies your device. Run {} to show it at all times.",
84        term::format::highlight(profile.did()),
85        term::format::command("rad self")
86    );
87    term::success!("You're all set.");
88    term::blank();
89
90    if profile.config.cli.hints && !agent {
91        term::hint("install ssh-agent to have it fill in your passphrase for you when signing.");
92        term::blank();
93    }
94    term::info!(
95        "To create a Radicle repository, run {} from a Git repository with at least one commit.",
96        term::format::command("rad init")
97    );
98    term::info!(
99        "To clone a repository, run {}. For example, {} clones the Radicle 'heartwood' repository.",
100        term::format::command("rad clone <rid>"),
101        term::format::command("rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5")
102    );
103    term::info!(
104        "To get a list of all commands, run {}.",
105        term::format::command("rad"),
106    );
107
108    Ok(())
109}
110
111/// Try loading the identity's key into SSH Agent, falling back to verifying `RAD_PASSPHRASE` for
112/// use.
113pub fn authenticate(args: Args, profile: &Profile) -> anyhow::Result<()> {
114    if !profile.keystore.is_encrypted()? {
115        term::success!("Authenticated as {}", term::format::tertiary(profile.id()));
116        return Ok(());
117    }
118    for (key, _) in &profile.config.node.extra {
119        term::warning(format!(
120            "unused or deprecated configuration attribute {key:?}"
121        ));
122    }
123
124    // If our key is encrypted, we try to authenticate with SSH Agent and
125    // register it; only if it is running.
126    match ssh::agent::Agent::connect() {
127        Ok(mut agent) => {
128            if agent.request_identities()?.contains(&profile.public_key) {
129                term::success!("Radicle key already in ssh-agent");
130                return Ok(());
131            }
132            let passphrase = if let Some(phrase) = profile::env::passphrase() {
133                phrase
134            } else if args.stdin {
135                term::passphrase_stdin()?
136            } else if let Some(passphrase) =
137                term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
138            {
139                passphrase
140            } else {
141                anyhow::bail!(
142                    "A passphrase is required to read your Radicle key. Unable to continue."
143                )
144            };
145            register(&mut agent, profile, passphrase)?;
146
147            term::success!("Radicle key added to {}", term::format::dim("ssh-agent"));
148
149            return Ok(());
150        }
151        Err(e) if e.is_not_running() => {}
152        Err(e) => Err(e)?,
153    };
154
155    // Try RAD_PASSPHRASE fallback.
156    if let Some(passphrase) = profile::env::passphrase() {
157        ssh::keystore::MemorySigner::load(&profile.keystore, Some(passphrase))
158            .map_err(|_| anyhow!("`{}` is invalid", env::RAD_PASSPHRASE))?;
159        return Ok(());
160    }
161
162    term::print(term::format::dim(
163        "Nothing to do, ssh-agent is not running.",
164    ));
165    term::print(term::format::dim(
166        "You will be prompted for a passphrase when necessary.",
167    ));
168
169    Ok(())
170}
171
172/// Register key with ssh-agent.
173pub fn register(
174    agent: &mut ssh::agent::Agent,
175    profile: &Profile,
176    passphrase: Passphrase,
177) -> anyhow::Result<()> {
178    let secret = profile
179        .keystore
180        .secret_key(Some(passphrase))
181        .map_err(|e| {
182            if e.is_crypto_err() {
183                anyhow!("could not decrypt secret key: invalid passphrase")
184            } else {
185                e.into()
186            }
187        })?
188        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.secret_key_path()))?;
189
190    agent.register(&secret)?;
191
192    Ok(())
193}