auths-cli 0.1.3

Command-line interface for Auths decentralized identity system
Documentation
//! LAN pairing mode — zero server required.
//!
//! Starts an ephemeral HTTP server on the local network. The mobile app
//! connects directly via the IP:port embedded in the QR code.

use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result};
use console::style;

use auths_sdk::core_config::EnvironmentConfig;
use auths_sdk::pairing::CreateSessionRequest;
use auths_sdk::pairing::{PairingToken, QrOptions, render_qr};
use auths_sdk::signing::PassphraseProvider;

use super::common::*;
use super::lan_server::{LanPairingServer, detect_lan_ip};

/// Initiate a LAN pairing session.
///
/// 1. Detect LAN IP
/// 2. Start ephemeral HTTP server
/// 3. Generate pairing token pointing at `http://LAN_IP:PORT`
/// 4. Optionally advertise via mDNS
/// 5. Display QR + short code
/// 6. Wait for response
/// 7. Verify + create attestation
// Flags are all flat UX toggles — grouping them into a struct would cost
// more at call sites (build the struct, name the fields) than it buys in
// clarity. Accept the lint.
#[allow(clippy::too_many_arguments)]
pub async fn handle_initiate_lan(
    now: chrono::DateTime<chrono::Utc>,
    no_qr: bool,
    no_mdns: bool,
    verify: bool,
    recover: Option<String>,
    expiry_secs: u64,
    capabilities: &[auths_keri::Capability],
    passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
    env_config: &EnvironmentConfig,
) -> Result<()> {
    // Recovery (--recover) is a one-shot pair-replacement-then-revoke flow under the
    // delegation model, but only over the relay path today (the LAN joiner is an
    // external app). Redirect LAN recovery to online recovery or the manual path.
    if let Some(ref old_did) = recover {
        let _ = old_did;
        return Err(anyhow::anyhow!(
            "Device recovery (--recover) over LAN is not yet supported. Use online recovery \
             (`auths pair --recover <did> --registry <url>`), or remove the lost device with \
             `auths device remove <did>` and pair a replacement."
        ));
    }
    let auths_dir = auths_sdk::paths::auths_home_with_config(env_config)
        .context("Could not determine Auths home directory. Check $AUTHS_HOME or $HOME.")?;

    let identity_storage = auths_sdk::storage::RegistryIdentityStorage::new(auths_dir.clone());
    let controller_did =
        auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?;

    // Detect LAN IP
    let lan_ip =
        detect_lan_ip().context("Failed to detect LAN IP. Are you connected to a network?")?;

    let expiry = chrono::Duration::seconds(expiry_secs as i64);

    // Generate a session token with a placeholder endpoint — we'll update it after
    // the server starts and we know the actual port.
    let mut session = PairingToken::generate_with_expiry(
        now,
        controller_did.clone(),
        "http://placeholder".to_string(), // replaced below
        capabilities.to_vec(),
        expiry,
    )
    .context("Failed to generate pairing token")?;

    let session_id = session.token.session_id.clone();

    // Build the CreateSessionRequest for the LAN server.
    let request = CreateSessionRequest {
        session_id: session_id.clone(),
        controller_did: session.token.controller_did.clone(),
        ephemeral_pubkey: auths_sdk::pairing::Base64UrlEncoded::from_raw(
            session.token.ephemeral_pubkey.clone(),
        ),
        short_code: session.token.short_code.clone(),
        capabilities: session.token.capabilities.clone(),
        expires_at: session.token.expires_at.timestamp(),
        recovery_target: recover.clone(),
    };

    // Start the LAN server bound to the detected LAN IP
    let mut server = LanPairingServer::start(request, lan_ip).await?;
    let port = server.addr().port();
    let endpoint = format!("http://{}:{}", lan_ip, port);

    // Update the token's endpoint to include the pairing token for auth
    session.token.endpoint = format!("{}?token={}", endpoint, server.pairing_token());

    // Self-test: verify the server is reachable from this machine
    let health_url = format!("{}/health", &endpoint);
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(3))
        .build()
        .unwrap_or_default();
    match client.get(&health_url).send().await {
        Ok(resp) if resp.status().is_success() => {}
        _ => {
            let warning = format!(
                "LAN server started but self-test failed. Your Mac's firewall may be blocking port {}.",
                port
            );
            println!(
                "  {} {}",
                style("Warning:").yellow().bold(),
                style(&warning).yellow()
            );
            println!(
                "  {} Try: {}",
                style("").yellow(),
                style("sudo pfctl -d").bold()
            );
            println!();
        }
    }

    print_pairing_header("LAN", &endpoint, &controller_did);

    // Optionally start mDNS advertisement via the daemon handle
    let _advertiser = if !no_mdns {
        match server.advertise(port, &session.token.short_code, &controller_did) {
            Some(Ok(adv)) => {
                println!(
                    "  {} {}",
                    style("mDNS:").dim(),
                    style("advertising on local network").green()
                );
                Some(adv)
            }
            Some(Err(e)) => {
                println!("  {} {} {}", WARN, style("mDNS unavailable:").yellow(), e);
                None
            }
            None => {
                println!(
                    "  {} {} not available (mdns feature disabled)",
                    WARN,
                    style("mDNS:").yellow()
                );
                None
            }
        }
    } else {
        None
    };

    // Display QR code
    if !no_qr {
        println!();
        let options = QrOptions::default();
        let qr = render_qr(&session.token, &options).context("Failed to render QR code")?;
        println!("{}", qr);
    }

    // Display short code
    let sc = &session.token.short_code;
    let formatted_code = format!("{}-{}", &sc[..3], &sc[3..]);

    println!();
    println!("  Scan the QR code above, or enter this code manually:");
    println!();
    println!("    {}", style(&formatted_code).bold().cyan());
    println!();
    if !capabilities.is_empty() {
        println!(
            "  {} {}",
            style("Capabilities:").dim(),
            capabilities
                .iter()
                .map(|c| c.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
    println!(
        "  {} {} ({}s remaining)",
        style("Expires:").dim(),
        session.token.expires_at.format("%H:%M:%S"),
        expiry_secs
    );
    println!();
    println!("  {}", style("(Press Ctrl+C to cancel)").dim());
    println!();
    println!(
        "  {} Test from another terminal: {}",
        style("Debug:").dim(),
        style(format!("curl {}/health", &endpoint)).dim()
    );
    println!();

    // Wait for response
    let wait_spinner = create_wait_spinner(&format!("{PHONE}Waiting for device on LAN..."));

    let expiry_duration = Duration::from_secs(expiry_secs);
    match server.wait_for_response(expiry_duration).await {
        Ok(response_data) => {
            wait_spinner.finish_with_message(format!("{CHECK}Response received!"));

            if let Some(adv) = _advertiser {
                adv.shutdown();
            }

            // Phone auto-fires /confirm as soon as it processes
            // /response. Keep the listener up briefly so that confirm
            // actually lands and the session is recorded as fully
            // settled. If the phone fails to confirm within the
            // window, we still proceed — phone-side logic tolerates
            // a silent daemon.
            let _confirmation = server.wait_for_confirmation(Duration::from_secs(5)).await;

            // Pair is the only session path — no mode branching.
            // Local device-key rotation goes through `auths identity rotate`,
            // never through the daemon.
            handle_pairing_response(
                now,
                &mut session,
                response_data,
                &auths_dir,
                capabilities,
                Arc::clone(&passphrase_provider),
                env_config,
                verify,
            )?;

            server.shutdown();
        }
        Err(auths_sdk::error::PairingError::LanTimeout) => {
            wait_spinner.finish_with_message(format!("{}", style("Session expired.").yellow()));
            if let Some(adv) = _advertiser {
                adv.shutdown();
            }
            server.shutdown();
        }
        Err(e) => {
            wait_spinner.finish_and_clear();
            if let Some(adv) = _advertiser {
                adv.shutdown();
            }
            server.shutdown();
            return Err(anyhow::anyhow!("LAN pairing failed: {}", e));
        }
    }

    Ok(())
}

/// Join a LAN pairing session by discovering it via mDNS.
pub async fn handle_join_lan(
    now: chrono::DateTime<chrono::Utc>,
    code: &str,
    env_config: &EnvironmentConfig,
) -> Result<()> {
    use auths_sdk::pairing::normalize_short_code;

    let normalized = normalize_short_code(code);
    if normalized.len() != 6 {
        anyhow::bail!(
            "Short code must be exactly 6 characters (got {})",
            normalized.len()
        );
    }

    let formatted = format!("{}-{}", &normalized[..3], &normalized[3..]);

    println!();
    println!(
        "{}",
        style(format!("━━━ {LINK}Discovering LAN Peer ━━━")).bold()
    );
    println!();
    println!(
        "  {} {}",
        style("Code:").dim(),
        style(&formatted).bold().cyan()
    );
    println!();

    let discover_spinner = create_wait_spinner(&format!("{GEAR}Searching for peer via mDNS..."));

    // Discover the LAN server via mDNS (30 second timeout)
    let discovery = auths_pairing_daemon::MdnsDiscovery;
    let addr = auths_pairing_daemon::NetworkDiscovery::discover(
        &discovery,
        &normalized,
        Duration::from_secs(30),
    )
    .map_err(|e| anyhow::anyhow!("mDNS discovery failed: {}", e))?;

    discover_spinner.finish_with_message(format!("{CHECK}Found peer at {}", style(addr).cyan()));

    let registry = format!("http://{}", addr);

    // Delegate to the standard join flow
    super::join::handle_join(now, &normalized, &registry, env_config).await
}