auths-cli 0.1.3

Command-line interface for Auths decentralized identity system
Documentation
//! Device pairing commands.
//!
//! Provides commands to initiate and join device pairing sessions
//! for cross-device identity linking using X25519 ECDH key exchange.

mod common;
mod join;
#[cfg(feature = "lan-pairing")]
mod lan;
#[cfg(feature = "lan-pairing")]
mod lan_server;
mod offline;
mod online;

use std::sync::Arc;

use anyhow::Result;
use auths_sdk::core_config::EnvironmentConfig;
use auths_sdk::signing::PassphraseProvider;
use chrono::Utc;
use clap::Parser;

/// Default registry URL for local development.
#[cfg(not(feature = "lan-pairing"))]
const DEFAULT_REGISTRY: &str = "http://localhost:3000";

#[derive(Parser, Debug, Clone)]
#[command(
    about = "Link devices to your identity",
    after_help = "Examples:
  auths pair                # Start LAN pairing session (shows QR code)
  auths pair --join CODE    # Join an existing pairing session using short code
  auths pair --registry URL # Pair via relay server
  auths pair --offline      # Offline mode (for testing, no network)

Modes:
  LAN mode (default)  — Local pairing via mDNS, fastest and most secure
  Online mode         — Via relay server, works across networks
  Offline mode        — For testing, no network required

Related:
  auths status  — Check linked devices
  auths device  — Manage device authorizations
  auths init    — Initial setup wizard"
)]
pub struct PairCommand {
    /// Join an existing pairing session using a short code
    #[clap(long, value_name = "CODE")]
    pub join: Option<String>,

    /// Registry URL for pairing relay (omit for LAN mode)
    #[clap(long, value_name = "URL")]
    pub registry: Option<String>,

    /// Don't display QR code (only show short code)
    #[clap(long, hide_short_help = true)]
    pub no_qr: bool,

    /// Custom timeout in seconds for the pairing session (default: 300 = 5 minutes)
    #[clap(
        long,
        visible_alias = "expiry",
        value_name = "SECONDS",
        default_value = "300"
    )]
    pub timeout: u64,

    /// Skip registry server (offline mode, for testing)
    #[clap(long, hide_short_help = true)]
    pub offline: bool,

    /// Capabilities to grant the paired device (comma-separated)
    #[clap(
        long,
        value_delimiter = ',',
        default_value = "sign_commit",
        hide_short_help = true
    )]
    pub capabilities: Vec<auths_keri::Capability>,

    /// Disable mDNS advertisement/discovery in LAN mode
    #[cfg(feature = "lan-pairing")]
    #[clap(long, hide_short_help = true)]
    pub no_mdns: bool,

    /// Prompt to manually verify the short-authentication-string (SAS)
    /// codes match between devices before completing the pair.
    ///
    /// Default is off — the QR scan is treated as the authenticated
    /// out-of-band channel, matching Signal/WhatsApp UX. Opt in here
    /// if you're pairing over an untrusted network and want a visual
    /// SAS compare.
    #[clap(long)]
    pub verify: bool,

    /// Lost/stolen-device recovery: pair a replacement delegated device, then
    /// revoke the old device's delegation (by `did:keri:`). The replacement is
    /// authorized before the old one is revoked, so the identity is never left
    /// with zero usable devices. Supported over the relay path (with `--registry`).
    #[clap(long, value_name = "OLD_DID")]
    pub recover: Option<String>,
}

/// Dispatch table:
///
/// | Flags                        | Behavior                              |
/// |------------------------------|---------------------------------------|
/// | `pair` (no flags)            | LAN mode: start local server, show QR |
/// | `pair --registry URL`        | Online mode (existing)                |
/// | `pair --join CODE`           | LAN join: mDNS discover -> join       |
/// | `pair --join CODE --registry`| Online join (existing)                |
/// | `pair --offline`             | Offline mode (no network)             |
pub fn handle_pair(
    cmd: PairCommand,
    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
    env_config: &EnvironmentConfig,
) -> Result<()> {
    #[allow(clippy::disallowed_methods)]
    let now = Utc::now();

    // Eagerly start the auths-agent so future signings in the same
    // terminal session can short-circuit to it. Quiet + best-effort:
    // if the agent can't start (perms, disk full, etc.), we proceed
    // via the passphrase provider path — never abort pair on agent
    // startup failure.
    if let Err(e) = crate::commands::agent::ensure_agent_running(true) {
        log::debug!("auths-agent auto-start skipped: {e}");
    }

    match (&cmd.join, &cmd.registry, cmd.offline) {
        // Offline mode takes priority
        (None, _, true) => {
            offline::handle_initiate_offline(now, cmd.no_qr, cmd.timeout, &cmd.capabilities)
        }

        // Join with explicit registry -> online join
        (Some(code), Some(registry), _) => {
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(join::handle_join(now, code, registry, env_config))
        }

        // Join without registry -> LAN join via mDNS
        #[cfg(feature = "lan-pairing")]
        (Some(code), None, _) => {
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(lan::handle_join_lan(now, code, env_config))
        }

        // Join without registry and no LAN feature -> use default registry
        #[cfg(not(feature = "lan-pairing"))]
        (Some(code), None, _) => {
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(join::handle_join(now, code, DEFAULT_REGISTRY, env_config))
        }

        // Initiate with explicit registry -> online mode
        (None, Some(registry), _) => {
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(online::handle_initiate_online(
                now,
                registry,
                cmd.no_qr,
                cmd.timeout,
                &cmd.capabilities,
                env_config,
                cmd.recover.clone(),
            ))
        }

        // Initiate without registry -> LAN mode
        #[cfg(feature = "lan-pairing")]
        (None, None, false) => {
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(lan::handle_initiate_lan(
                now,
                cmd.no_qr,
                cmd.no_mdns,
                cmd.verify,
                cmd.recover.clone(),
                cmd.timeout,
                &cmd.capabilities,
                passphrase_provider,
                env_config,
            ))
        }

        // Initiate without registry and no LAN feature -> use default registry
        #[cfg(not(feature = "lan-pairing"))]
        (None, None, false) => {
            let rt = tokio::runtime::Runtime::new()?;
            rt.block_on(online::handle_initiate_online(
                now,
                DEFAULT_REGISTRY,
                cmd.no_qr,
                cmd.timeout,
                &cmd.capabilities,
                env_config,
                cmd.recover.clone(),
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use auths_sdk::pairing::normalize_short_code;

    #[test]
    fn test_code_normalization() {
        let codes = vec![
            ("AB3DEF", "AB3DEF"),
            ("ab3def", "AB3DEF"),
            ("AB3 DEF", "AB3DEF"),
            ("AB3-DEF", "AB3DEF"),
            ("a b 3 d e f", "AB3DEF"),
        ];

        for (input, expected) in codes {
            let normalized = normalize_short_code(input);
            assert_eq!(normalized, expected, "Input: '{}'", input);
        }
    }
}