Skip to main content

cast/cmd/wallet/
touch_id.rs

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