auths-cli 0.1.12

Command-line interface for Auths decentralized identity system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
//! 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,
    print_uri: 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
    );
    if print_uri {
        // The token's endpoint is final here (set right after server start),
        // so this is the same URI the QR above encodes.
        println!();
        print_token_uri(&session.token);
    }
    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(())
}

/// Receive a phone-authored shared-KEL rotation over an ephemeral LAN session.
///
/// The host is transport only here — it authors nothing and signs nothing:
/// 1. Start a one-shot LAN session and display the QR / short code.
/// 2. The device scans, binds its signing key (`POST /response`), and
///    submits the co-authored rotation envelope (`POST /shared-kel-rot`),
///    which the daemon signature-gates before holding.
/// 3. Return the held envelope; the caller validates it against the
///    registry's prior key state and appends it
///    (`auths_sdk::domains::identity::shared_rot::apply_shared_kel_rot`).
///
/// Args:
/// * `now`: Injected clock for session expiry.
/// * `repo_path`: The identity registry whose controller this session serves.
/// * `no_qr` / `print_uri` / `no_mdns`: Display + discovery toggles (same as `pair`).
/// * `expiry_secs`: Session lifetime.
pub async fn handle_receive_shared_rot(
    now: chrono::DateTime<chrono::Utc>,
    repo_path: &std::path::Path,
    no_qr: bool,
    print_uri: bool,
    no_mdns: bool,
    expiry_secs: u64,
) -> Result<String> {
    let identity_storage =
        auths_sdk::storage::RegistryIdentityStorage::new(repo_path.to_path_buf());
    let controller_did =
        auths_sdk::pairing::load_controller_did(&identity_storage).map_err(anyhow::Error::from)?;

    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);

    // A rotation-receive session grants no capabilities — the envelope's own
    // indexed signatures (and the registry's prior state) are the authority.
    let mut session = PairingToken::generate_with_expiry(
        now,
        controller_did.clone(),
        "http://placeholder".to_string(), // replaced below
        vec![],
        expiry,
    )
    .context("Failed to generate session token")?;

    let request = CreateSessionRequest {
        session_id: session.token.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: None,
    };

    let mut server = LanPairingServer::start(request, lan_ip).await?;
    let port = server.addr().port();
    let endpoint = format!("http://{}:{}", lan_ip, port);
    session.token.endpoint = format!("{}?token={}", endpoint, server.pairing_token());

    println!();
    println!(
        "{}",
        style(format!("━━━ {PHONE}Receive Identity Rotation (LAN) ━━━")).bold()
    );
    println!();
    println!(
        "  {} {}",
        style("Identity:").dim(),
        style(&controller_did).cyan()
    );
    println!();

    let _advertiser = if !no_mdns {
        match server.advertise(port, &session.token.short_code, &controller_did) {
            Some(Ok(adv)) => Some(adv),
            Some(Err(e)) => {
                println!("  {} {} {}", WARN, style("mDNS unavailable:").yellow(), e);
                None
            }
            None => None,
        }
    } else {
        None
    };

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

    let sc = &session.token.short_code;
    println!();
    println!("  Scan the QR code above, or enter this code on the device:");
    println!();
    println!(
        "    {}",
        style(format!("{}-{}", &sc[..3], &sc[3..])).bold().cyan()
    );
    println!();
    if print_uri {
        print_token_uri(&session.token);
        println!();
    }

    let wait_spinner = create_wait_spinner(&format!("{PHONE}Waiting for the device's rotation..."));
    let expiry_duration = Duration::from_secs(expiry_secs);

    let result: Result<String> = async {
        server
            .wait_for_response(expiry_duration)
            .await
            .map_err(|e| anyhow::anyhow!("device never connected: {e}"))?;
        // The device is bound; the rotation envelope follows on the same
        // session. Reuse the full window — the daemon enforces session TTL.
        let held = server
            .wait_for_shared_kel_rot(expiry_duration)
            .await
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "the device connected but submitted no rotation before the session expired"
                )
            })?;
        Ok(held.rot_envelope)
    }
    .await;

    if let Some(adv) = _advertiser {
        adv.shutdown();
    }
    match &result {
        Ok(_) => wait_spinner.finish_with_message(format!("{CHECK}Rotation received!")),
        Err(_) => wait_spinner.finish_and_clear(),
    }
    server.shutdown();
    result
}

/// 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
}