Skip to main content

auths_cli/commands/
key_detect.rs

1//! Shared device key alias auto-detection.
2
3use std::path::Path;
4
5use anyhow::{Context, Result, anyhow};
6use auths_sdk::core_config::EnvironmentConfig;
7use auths_sdk::keychain::{IdentityDID, KeyAlias, KeyStorage};
8use auths_sdk::ports::IdentityStorage;
9use auths_sdk::storage::RegistryIdentityStorage;
10use dialoguer::Select;
11use std::io::IsTerminal;
12
13use crate::ux::format::is_json_mode;
14
15fn filter_signing_aliases(aliases: Vec<KeyAlias>) -> Vec<KeyAlias> {
16    aliases
17        .into_iter()
18        .filter(|a| !a.as_str().contains("--next-"))
19        .collect()
20}
21
22/// This root's live delegated device #0 as an `IdentityDID`, or `None` if the root has no
23/// delegated device.
24///
25/// Device #0's signing key is stored in the keychain under the device's OWN AID (not the
26/// root's), so day-to-day signing is the device's — distinct from the root identity. This
27/// selection mirrors `resolve_local_signer` (first non-revoked delegation) so the key that
28/// signs matches the `Auths-Device` trailer the signer reports.
29///
30/// Args:
31/// * `repo_path`: Path to the identity repository (the KEL/registry root).
32/// * `root_did`: The root controller's `did:keri`.
33///
34/// Usage:
35/// ```ignore
36/// let signer = delegated_primary_device_did(&repo_path, &identity.controller_did)
37///     .unwrap_or_else(|| identity.controller_did.clone());
38/// ```
39fn delegated_primary_device_did(repo_path: &Path, root_did: &IdentityDID) -> Option<IdentityDID> {
40    let root_prefix = auths_sdk::keri::parse_did_keri(root_did.as_str()).ok()?;
41    let backend = auths_sdk::storage::GitRegistryBackend::from_config_unchecked(
42        auths_sdk::storage::RegistryConfig::single_tenant(repo_path),
43    );
44    let devices =
45        auths_sdk::keri::delegation::list_delegated_devices(&backend, &root_prefix).ok()?;
46    let device = devices.iter().find(|d| !d.revoked)?;
47    IdentityDID::from_prefix(device.device_prefix.as_str()).ok()
48}
49
50fn select_device_key_interactive(aliases: &[KeyAlias]) -> Result<String> {
51    let display_items: Vec<&str> = aliases.iter().map(|a| a.as_str()).collect();
52
53    let selection = Select::new()
54        .with_prompt("Select signing key")
55        .items(&display_items)
56        .default(0)
57        .interact()
58        .context("Key selection cancelled")?;
59
60    Ok(aliases[selection].as_str().to_string())
61}
62
63/// Auto-detect the device key alias when not explicitly provided.
64///
65/// Loads the identity from the repository, then lists all key aliases
66/// associated with that identity. Filters out `--next-` rotation keys,
67/// then either auto-selects (single key) or prompts interactively (multiple keys).
68///
69/// Args:
70/// * `repo_opt`: Optional path to the identity repository.
71/// * `env_config`: Environment configuration for keychain access.
72///
73/// Usage:
74/// ```ignore
75/// let alias = auto_detect_device_key(repo_opt.as_deref(), env_config)?;
76/// ```
77pub fn auto_detect_device_key(
78    repo_opt: Option<&Path>,
79    env_config: &EnvironmentConfig,
80) -> Result<String> {
81    let repo_path =
82        auths_sdk::storage_layout::resolve_repo_path(repo_opt.map(|p| p.to_path_buf()))?;
83    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
84    let identity = identity_storage
85        .load_identity()
86        .map_err(|_| anyhow!("No identity found. Run `auths init` to get started."))?;
87
88    let keychain = auths_sdk::keychain::get_platform_keychain_with_config(env_config)
89        .context("Failed to access keychain")?;
90    // Prefer this root's delegated device #0's OWN key (stored under the device's AID), so
91    // the device SIGNATURE is device #0's, not the root's. Fall back to the root's keys
92    // (a root with no delegated device — e.g. CI signing directly).
93    let signing_identity = delegated_primary_device_did(&repo_path, &identity.controller_did)
94        .unwrap_or_else(|| identity.controller_did.clone());
95    let aliases = keychain
96        .list_aliases_for_identity(&signing_identity)
97        .map_err(|e| anyhow!("Failed to list key aliases: {e}"))?;
98
99    let signing_aliases = filter_signing_aliases(aliases);
100
101    match signing_aliases.len() {
102        0 => Err(anyhow!(
103            "No signing keys found for identity {}.\n\n\
104             All keys are rotation keys (--next- prefixed) or no keys exist.\n\
105             Run `auths status` to see your identity details, or `auths pair` to link another device.",
106            identity.controller_did
107        )),
108        1 => Ok(signing_aliases[0].as_str().to_string()),
109        _ => {
110            if std::io::stdin().is_terminal() && !is_json_mode() {
111                select_device_key_interactive(&signing_aliases)
112            } else {
113                let alias_list: Vec<&str> = signing_aliases.iter().map(|a| a.as_str()).collect();
114                Err(anyhow!(
115                    "Multiple device keys found. Specify with --device-key.\n\n\
116                     Available aliases: {}",
117                    alias_list.join(", ")
118                ))
119            }
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use auths_sdk::keychain::KeyAlias;
128
129    #[test]
130    fn filter_removes_next_aliases() {
131        let aliases = vec![
132            KeyAlias::new("main").unwrap(),
133            KeyAlias::new("main--next-0").unwrap(),
134            KeyAlias::new("secondary").unwrap(),
135            KeyAlias::new("main--next-1").unwrap(),
136        ];
137        let result = filter_signing_aliases(aliases);
138        let names: Vec<&str> = result.iter().map(|a| a.as_str()).collect();
139        assert_eq!(names, vec!["main", "secondary"]);
140    }
141
142    #[test]
143    fn filter_all_next_returns_empty() {
144        let aliases = vec![
145            KeyAlias::new("main--next-0").unwrap(),
146            KeyAlias::new("main--next-1").unwrap(),
147        ];
148        let result = filter_signing_aliases(aliases);
149        assert!(result.is_empty());
150    }
151}