Skip to main content

auths_cli/commands/device/pair/
mod.rs

1//! Device pairing commands.
2//!
3//! Provides commands to initiate and join device pairing sessions
4//! for cross-device identity linking using X25519 ECDH key exchange.
5
6mod common;
7mod join;
8#[cfg(feature = "lan-pairing")]
9mod lan;
10#[cfg(feature = "lan-pairing")]
11mod lan_server;
12mod offline;
13mod online;
14
15use std::sync::Arc;
16
17use anyhow::Result;
18use auths_sdk::core_config::EnvironmentConfig;
19use auths_sdk::signing::PassphraseProvider;
20use chrono::Utc;
21use clap::Parser;
22
23/// Default registry URL for local development.
24#[cfg(not(feature = "lan-pairing"))]
25const DEFAULT_REGISTRY: &str = "http://localhost:3000";
26
27#[derive(Parser, Debug, Clone)]
28#[command(
29    about = "Link devices to your identity",
30    after_help = "Examples:
31  auths pair                # Start LAN pairing session (shows QR code)
32  auths pair --join CODE    # Join an existing pairing session using short code
33  auths pair --registry URL # Pair via relay server
34  auths pair --offline      # Offline mode (for testing, no network)
35
36Modes:
37  LAN mode (default)  — Local pairing via mDNS, fastest and most secure
38  Online mode         — Via relay server, works across networks
39  Offline mode        — For testing, no network required
40
41Related:
42  auths status  — Check linked devices
43  auths device  — Manage device authorizations
44  auths init    — Initial setup wizard"
45)]
46pub struct PairCommand {
47    /// Join an existing pairing session using a short code
48    #[clap(long, value_name = "CODE")]
49    pub join: Option<String>,
50
51    /// Registry URL for pairing relay (omit for LAN mode)
52    #[clap(long, value_name = "URL")]
53    pub registry: Option<String>,
54
55    /// Don't display QR code (only show short code)
56    #[clap(long, hide_short_help = true)]
57    pub no_qr: bool,
58
59    /// Custom timeout in seconds for the pairing session (default: 300 = 5 minutes)
60    #[clap(
61        long,
62        visible_alias = "expiry",
63        value_name = "SECONDS",
64        default_value = "300"
65    )]
66    pub timeout: u64,
67
68    /// Skip registry server (offline mode, for testing)
69    #[clap(long, hide_short_help = true)]
70    pub offline: bool,
71
72    /// Capabilities to grant the paired device (comma-separated)
73    #[clap(
74        long,
75        value_delimiter = ',',
76        default_value = "sign_commit",
77        hide_short_help = true
78    )]
79    pub capabilities: Vec<String>,
80
81    /// Disable mDNS advertisement/discovery in LAN mode
82    #[cfg(feature = "lan-pairing")]
83    #[clap(long, hide_short_help = true)]
84    pub no_mdns: bool,
85
86    /// Prompt to manually verify the short-authentication-string (SAS)
87    /// codes match between devices before completing the pair.
88    ///
89    /// Default is off — the QR scan is treated as the authenticated
90    /// out-of-band channel, matching Signal/WhatsApp UX. Opt in here
91    /// if you're pairing over an untrusted network and want a visual
92    /// SAS compare.
93    #[clap(long)]
94    pub verify: bool,
95
96    /// Lost/stolen-device recovery: pair a replacement delegated device, then
97    /// revoke the old device's delegation (by `did:keri:`). The replacement is
98    /// authorized before the old one is revoked, so the identity is never left
99    /// with zero usable devices. Supported over the relay path (with `--registry`).
100    #[clap(long, value_name = "OLD_DID")]
101    pub recover: Option<String>,
102}
103
104/// Dispatch table:
105///
106/// | Flags                        | Behavior                              |
107/// |------------------------------|---------------------------------------|
108/// | `pair` (no flags)            | LAN mode: start local server, show QR |
109/// | `pair --registry URL`        | Online mode (existing)                |
110/// | `pair --join CODE`           | LAN join: mDNS discover -> join       |
111/// | `pair --join CODE --registry`| Online join (existing)                |
112/// | `pair --offline`             | Offline mode (no network)             |
113pub fn handle_pair(
114    cmd: PairCommand,
115    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
116    env_config: &EnvironmentConfig,
117) -> Result<()> {
118    #[allow(clippy::disallowed_methods)]
119    let now = Utc::now();
120
121    // Eagerly start the auths-agent so future signings in the same
122    // terminal session can short-circuit to it. Quiet + best-effort:
123    // if the agent can't start (perms, disk full, etc.), we proceed
124    // via the passphrase provider path — never abort pair on agent
125    // startup failure.
126    if let Err(e) = crate::commands::agent::ensure_agent_running(true) {
127        log::debug!("auths-agent auto-start skipped: {e}");
128    }
129
130    match (&cmd.join, &cmd.registry, cmd.offline) {
131        // Offline mode takes priority
132        (None, _, true) => {
133            offline::handle_initiate_offline(now, cmd.no_qr, cmd.timeout, &cmd.capabilities)
134        }
135
136        // Join with explicit registry -> online join
137        (Some(code), Some(registry), _) => {
138            let rt = tokio::runtime::Runtime::new()?;
139            rt.block_on(join::handle_join(now, code, registry, env_config))
140        }
141
142        // Join without registry -> LAN join via mDNS
143        #[cfg(feature = "lan-pairing")]
144        (Some(code), None, _) => {
145            let rt = tokio::runtime::Runtime::new()?;
146            rt.block_on(lan::handle_join_lan(now, code, env_config))
147        }
148
149        // Join without registry and no LAN feature -> use default registry
150        #[cfg(not(feature = "lan-pairing"))]
151        (Some(code), None, _) => {
152            let rt = tokio::runtime::Runtime::new()?;
153            rt.block_on(join::handle_join(now, code, DEFAULT_REGISTRY, env_config))
154        }
155
156        // Initiate with explicit registry -> online mode
157        (None, Some(registry), _) => {
158            let rt = tokio::runtime::Runtime::new()?;
159            rt.block_on(online::handle_initiate_online(
160                now,
161                registry,
162                cmd.no_qr,
163                cmd.timeout,
164                &cmd.capabilities,
165                env_config,
166                cmd.recover.clone(),
167            ))
168        }
169
170        // Initiate without registry -> LAN mode
171        #[cfg(feature = "lan-pairing")]
172        (None, None, false) => {
173            let rt = tokio::runtime::Runtime::new()?;
174            rt.block_on(lan::handle_initiate_lan(
175                now,
176                cmd.no_qr,
177                cmd.no_mdns,
178                cmd.verify,
179                cmd.recover.clone(),
180                cmd.timeout,
181                &cmd.capabilities,
182                passphrase_provider,
183                env_config,
184            ))
185        }
186
187        // Initiate without registry and no LAN feature -> use default registry
188        #[cfg(not(feature = "lan-pairing"))]
189        (None, None, false) => {
190            let rt = tokio::runtime::Runtime::new()?;
191            rt.block_on(online::handle_initiate_online(
192                now,
193                DEFAULT_REGISTRY,
194                cmd.no_qr,
195                cmd.timeout,
196                &cmd.capabilities,
197                env_config,
198                cmd.recover.clone(),
199            ))
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use auths_sdk::pairing::normalize_short_code;
207
208    #[test]
209    fn test_code_normalization() {
210        let codes = vec![
211            ("AB3DEF", "AB3DEF"),
212            ("ab3def", "AB3DEF"),
213            ("AB3 DEF", "AB3DEF"),
214            ("AB3-DEF", "AB3DEF"),
215            ("a b 3 d e f", "AB3DEF"),
216        ];
217
218        for (input, expected) in codes {
219            let normalized = normalize_short_code(input);
220            assert_eq!(normalized, expected, "Input: '{}'", input);
221        }
222    }
223}