Skip to main content

cast/cmd/wallet/
touch_id.rs

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/// Arguments for `cast wallet touch-id`.
22#[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/// Touch ID lifecycle commands for encrypted keystores.
35#[derive(Debug, Parser)]
36enum TouchIdSubcommands {
37    /// Enroll an existing keystore for Touch ID-assisted authentication.
38    Enroll {
39        /// The name of the keystore account.
40        #[arg(value_name = "ACCOUNT_NAME")]
41        account_name: String,
42
43        /// The directory containing the keystore.
44        #[arg(long, short)]
45        keystore_dir: Option<String>,
46
47        /// The keystore password in cleartext.
48        #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")]
49        unsafe_password: Option<String>,
50    },
51
52    /// Report the persisted Touch ID enrollment state for a keystore.
53    Status {
54        /// The name of the keystore account.
55        #[arg(value_name = "ACCOUNT_NAME")]
56        account_name: String,
57
58        /// The directory containing the keystore.
59        #[arg(long, short)]
60        keystore_dir: Option<String>,
61    },
62
63    /// Remove the persisted Touch ID enrollment for a keystore.
64    Remove {
65        /// The name of the keystore account.
66        #[arg(value_name = "ACCOUNT_NAME")]
67        account_name: String,
68
69        /// The directory containing the keystore.
70        #[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}