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