radicle_cli/commands/
auth.rs1#![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
166pub 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 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 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
227pub 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}