1use clap::{Args, Parser};
2use eyre::Result;
3use foundry_cli::json::print_json_success;
4use foundry_common::{sh_println, shell};
5use foundry_config::Config;
6use serde_json::json;
7use std::path::PathBuf;
8
9use super::{
10 TouchIdSidecarState, ensure_account_name_available, ensure_touch_id_available,
11 remove_touch_id_sidecar, touch_id_sidecar_path, touch_id_sidecar_policy,
12 touch_id_sidecar_state,
13};
14
15#[cfg(all(target_os = "macos", feature = "touch-id"))]
16use alloy_signer_local::PrivateKeySigner;
17
18#[cfg(all(target_os = "macos", feature = "touch-id"))]
19use super::ensure_touch_id_sidecar_available;
20
21#[derive(Debug, Args)]
23pub struct TouchIdArgs {
24 #[command(subcommand)]
25 command: TouchIdSubcommands,
26}
27
28impl TouchIdArgs {
29 pub fn run(self) -> Result<()> {
30 self.command.run()
31 }
32}
33
34#[derive(Debug, Parser)]
36enum TouchIdSubcommands {
37 Enroll {
39 #[arg(value_name = "ACCOUNT_NAME")]
41 account_name: String,
42
43 #[arg(long, short)]
45 keystore_dir: Option<String>,
46
47 #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
49 unsafe_password: Option<String>,
50 },
51
52 Status {
54 #[arg(value_name = "ACCOUNT_NAME")]
56 account_name: String,
57
58 #[arg(long, short)]
60 keystore_dir: Option<String>,
61 },
62
63 Remove {
65 #[arg(value_name = "ACCOUNT_NAME")]
67 account_name: String,
68
69 #[arg(long, short)]
71 keystore_dir: Option<String>,
72 },
73}
74
75impl TouchIdSubcommands {
76 fn run(self) -> Result<()> {
77 match self {
78 Self::Enroll { account_name, keystore_dir, unsafe_password } => {
79 enroll(&account_name, keystore_dir, unsafe_password)
80 }
81 Self::Status { account_name, keystore_dir } => status(&account_name, keystore_dir),
82 Self::Remove { account_name, keystore_dir } => remove(&account_name, keystore_dir),
83 }
84 }
85}
86
87fn keystore_path(account_name: &str, keystore_dir: Option<String>) -> Result<PathBuf> {
88 ensure_account_name_available(account_name)?;
89 let keystore_dir = match keystore_dir {
90 Some(path) => PathBuf::from(path),
91 None => Config::foundry_keystores_dir()
92 .ok_or_else(|| eyre::eyre!("Could not find the default keystore directory."))?,
93 };
94 let keystore_path = keystore_dir.join(account_name);
95 if !keystore_path.exists() {
96 eyre::bail!("Keystore file does not exist at {}", keystore_path.display());
97 }
98 Ok(keystore_path)
99}
100
101fn status(account_name: &str, keystore_dir: Option<String>) -> Result<()> {
102 let keystore_path = keystore_path(account_name, keystore_dir)?;
103 let sidecar = touch_id_sidecar_path(&keystore_path);
104
105 match touch_id_sidecar_state(&sidecar)? {
106 TouchIdSidecarState::Missing => print_status(
107 json!({"account": account_name, "status": "not-enrolled"}),
108 format!("Touch ID is not enrolled for keystore `{account_name}`."),
109 ),
110 TouchIdSidecarState::Recognized => {
111 let policy = touch_id_sidecar_policy(&sidecar)?.as_str();
112 print_status(
113 json!({"account": account_name, "status": "enrolled", "policy": policy}),
114 format!(
115 "Touch ID is enrolled for keystore `{account_name}` with `{policy}` policy."
116 ),
117 )
118 }
119 TouchIdSidecarState::Keystore => print_status(
120 json!({"account": account_name, "status": "conflict"}),
121 format!(
122 "Touch ID status for keystore `{account_name}` is conflicted: {} is an existing keystore.",
123 sidecar.display()
124 ),
125 ),
126 TouchIdSidecarState::Unknown => print_status(
127 json!({"account": account_name, "status": "unknown"}),
128 format!(
129 "Touch ID status for keystore `{account_name}` is unknown: {} is not a recognized Touch ID sidecar.",
130 sidecar.display()
131 ),
132 ),
133 }
134}
135
136fn remove(account_name: &str, keystore_dir: Option<String>) -> Result<()> {
137 let keystore_path = keystore_path(account_name, keystore_dir)?;
138 let removed = remove_touch_id_sidecar(&keystore_path)?;
139 let message = if removed {
140 format!("Touch ID enrollment removed for keystore `{account_name}`.")
141 } else {
142 format!("Touch ID is not enrolled for keystore `{account_name}`.")
143 };
144 print_status(json!({"account": account_name, "removed": removed}), message)
145}
146
147#[cfg(all(target_os = "macos", feature = "touch-id"))]
148fn enroll(
149 account_name: &str,
150 keystore_dir: Option<String>,
151 unsafe_password: Option<String>,
152) -> Result<()> {
153 let keystore_path = keystore_path(account_name, keystore_dir)?;
154 ensure_touch_id_available(true)?;
155
156 let sidecar = touch_id_sidecar_path(&keystore_path);
157 let state = touch_id_sidecar_state(&sidecar)?;
158 ensure_touch_id_sidecar_available(&keystore_path)?;
159 let (reenrolled, policy) = match state {
160 TouchIdSidecarState::Missing => (false, foundry_wallets::touch_id::Policy::default()),
161 TouchIdSidecarState::Recognized => {
162 (true, foundry_wallets::touch_id::policy(&keystore_path)?)
163 }
164 TouchIdSidecarState::Keystore | TouchIdSidecarState::Unknown => {
165 eyre::bail!("Touch ID sidecar state changed during enrollment preflight");
166 }
167 };
168
169 let password = match unsafe_password {
170 Some(password) => password,
171 None => rpassword::prompt_password("Enter password: ")?,
172 };
173 PrivateKeySigner::decrypt_keystore(&keystore_path, &password)
174 .map_err(|_| eyre::eyre!("Invalid password - Touch ID enrollment cancelled"))?;
175
176 foundry_wallets::touch_id::enroll(&keystore_path, &password, policy).map_err(|error| {
177 let action = if reenrolled { "re-enrollment" } else { "enrollment" };
178 eyre::eyre!("Touch ID {action} failed for keystore `{account_name}`: {error}")
179 })?;
180
181 let message = if reenrolled {
182 format!("Touch ID re-enrolled for keystore `{account_name}`.")
183 } else {
184 format!("Touch ID enrolled for keystore `{account_name}`.")
185 };
186 print_status(
187 json!({"account": account_name, "touch_id": true, "reenrolled": reenrolled}),
188 message,
189 )
190}
191
192#[cfg(not(all(target_os = "macos", feature = "touch-id")))]
193fn enroll(
194 account_name: &str,
195 keystore_dir: Option<String>,
196 _unsafe_password: Option<String>,
197) -> Result<()> {
198 let _ = keystore_path(account_name, keystore_dir)?;
199 ensure_touch_id_available(true)
200}
201
202fn print_status(value: serde_json::Value, message: String) -> Result<()> {
203 if shell::is_json() { print_json_success(value) } else { sh_println!("{message}") }
204}