Skip to main content

cli/env/
identity.rs

1//! `shine env secret identity init/list`: generate and inspect age identities used
2//! to decrypt `age:`-tagged secrets, including Secure Enclave (Touch ID)
3//! identities minted by `age-plugin-se` or paired through `age-plugin-phone`.
4
5use crate::commands::PhoneRecipientType;
6use anyhow::{Context, Result, bail};
7use bech32::{FromBase32, Variant};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11use tokio::io::AsyncReadExt;
12use tokio::process::Command;
13
14use crate::config::Config;
15use crate::proc::ensure_command;
16use crate::{colors, path_display};
17
18const DEFAULT_ACCESS_CONTROL: &str = "any-biometry";
19const VALID_ACCESS_CONTROLS: &[&str] = &[
20    "any-biometry",
21    "any-biometry-or-passcode",
22    "current-biometry",
23    "passcode",
24];
25const PHONE_SETUP_RESULT_VERSION: u16 = 1;
26const MAX_PHONE_SETUP_RESULT_BYTES: usize = 16 * 1024;
27
28#[derive(Debug, Deserialize)]
29#[serde(deny_unknown_fields)]
30struct PhoneSetupResult {
31    schema_version: u16,
32    identity_path: PathBuf,
33    recipient: String,
34}
35
36#[derive(Serialize)]
37struct ManualAgeIdentities<'a> {
38    age_identities: &'a [String],
39}
40
41pub async fn handle_phone_identity_init(
42    config: &Config,
43    recipient_type: PhoneRecipientType,
44    label: Option<&str>,
45    transport: &str,
46    adb_serial: Option<&str>,
47) -> Result<()> {
48    ensure_phone_supported(std::env::consts::OS)?;
49    if config.project_overrides_age_identities() {
50        bail!(
51            "the active project explicitly overrides age identity configuration; remove or update that project override before pairing a phone-backed identity"
52        );
53    }
54    if recipient_type == PhoneRecipientType::Tag {
55        crate::secret::preflight_age().await?;
56    }
57    ensure_command("age-plugin-phone")?;
58    let system_label = if label.is_none() {
59        read_phone_computer_name().await
60    } else {
61        None
62    };
63    let label = resolve_phone_label(label, system_label.as_deref())?;
64    let result = run_phone_setup(
65        "age-plugin-phone",
66        recipient_type,
67        &label,
68        transport,
69        adb_serial,
70    )
71    .await?;
72    validate_phone_setup_result(&result, recipient_type).await?;
73
74    let identity_value = result
75        .identity_path
76        .to_str()
77        .context("phone identity path is not valid Unicode")?
78        .to_owned();
79    let mut global = Config::load_global_runtime_for_dry_run().await?;
80    add_age_identity_path(&mut global, &result.identity_path, identity_value);
81    if let Err(error) = global.save().await {
82        let manual = toml::to_string(&ManualAgeIdentities {
83            age_identities: &global.age_identities,
84        })
85        .unwrap_or_else(|_| "age_identities = [\"<phone identity path>\"]\n".to_string());
86        eprintln!(
87            "Phone pairing succeeded, but Shine could not update {}. The pairing remains active; do not start another setup. Add this to the global config manually:\n\n{}",
88            path_display::format(global.config_path()),
89            manual.trim_end()
90        );
91        return Err(error).context("saving the phone identity in global Shine config");
92    }
93
94    println!(
95        "{}",
96        colors::green(&format!(
97            "configured phone-backed age identity at {}",
98            path_display::format(&result.identity_path)
99        ))
100    );
101    println!("  recipient: {}", result.recipient);
102    println!(
103        "  global config: {}",
104        path_display::format(global.config_path())
105    );
106    println!();
107    println!(
108        "{}",
109        colors::dim(
110            "Add this phone recipient together with an independently verified recovery recipient to age_recipients or the workspace [env.encryption] table. Never use the preview phone recipient as the only recipient for retained data."
111        )
112    );
113    if global.secret_backend.as_deref() != Some("age") {
114        println!(
115            "{}",
116            colors::dim(
117                "The global secret_backend was not changed. Set secret_backend = \"age\" explicitly if age should become the default for encrypt/seal."
118            )
119        );
120    }
121    Ok(())
122}
123
124pub async fn handle_identity_init(
125    config: &Config,
126    touch_id: bool,
127    access_control: Option<&str>,
128    output: Option<&Path>,
129    force: bool,
130) -> Result<()> {
131    ensure_touch_id_supported(touch_id, std::env::consts::OS)?;
132    if !touch_id && access_control.is_some() {
133        bail!("--access-control only applies with --touch-id");
134    }
135    let access_control = access_control.unwrap_or(DEFAULT_ACCESS_CONTROL);
136    if touch_id {
137        validate_access_control(access_control)?;
138    }
139
140    let output_path = output
141        .map(Path::to_path_buf)
142        .unwrap_or_else(|| default_identity_path(config));
143    if output_path.exists() && !force {
144        bail!(
145            "{} already exists; pass --force to overwrite",
146            output_path.display()
147        );
148    }
149    if let Some(parent) = output_path.parent() {
150        tokio::fs::create_dir_all(parent)
151            .await
152            .with_context(|| format!("creating {}", parent.display()))?;
153    }
154
155    if touch_id {
156        ensure_command("age-plugin-se")?;
157        run_keygen(
158            "age-plugin-se",
159            &touch_id_keygen_args(access_control, &output_path),
160        )
161        .await?;
162    } else {
163        ensure_command("age-keygen")?;
164        run_keygen(
165            "age-keygen",
166            &["-o".to_string(), output_path.to_string_lossy().into_owned()],
167        )
168        .await?;
169    }
170
171    #[cfg(unix)]
172    set_owner_only_permissions(&output_path).await?;
173
174    let recipient = extract_recipient(&output_path).await?;
175    println!(
176        "{}",
177        colors::green(&format!(
178            "generated age identity at {}",
179            path_display::format(&output_path)
180        ))
181    );
182    println!("  recipient: {recipient}");
183    println!();
184    println!(
185        "{}",
186        colors::dim(
187            "Add this recipient to age_recipients in config.toml (or [env.encryption] in \
188             shine.workspace.toml) so others can decrypt secrets sealed for it."
189        )
190    );
191    if config.secret_backend.as_deref() != Some("age") {
192        println!(
193            "{}",
194            colors::dim(
195                "Set secret_backend = \"age\" in config.toml to make age the default for \
196                 `shine env secret encrypt`/`shine env secret seal`."
197            )
198        );
199    }
200    if config.age_identity.is_none() && output_path != default_identity_path(config) {
201        println!(
202            "{}",
203            colors::dim(&format!(
204                "Set age_identity = \"{}\" in config.toml so shine can find this identity.",
205                output_path.display()
206            ))
207        );
208    }
209    Ok(())
210}
211
212fn touch_id_keygen_args(access_control: &str, output_path: &Path) -> Vec<String> {
213    vec![
214        "keygen".to_string(),
215        "--recipient-type=tag".to_string(),
216        format!("--access-control={access_control}"),
217        "-o".to_string(),
218        output_path.to_string_lossy().into_owned(),
219    ]
220}
221
222pub async fn handle_identity_list(config: &Config) -> Result<()> {
223    let identities = config.resolved_age_identities();
224    if identities.is_empty() {
225        println!(
226            "{}",
227            colors::dim("No age identity configured. Run `shine env secret identity init`.")
228        );
229        return Ok(());
230    }
231    for identity in &identities {
232        let recipient = extract_recipient(identity).await?;
233        println!("{}  {}", path_display::format(identity), recipient);
234    }
235    Ok(())
236}
237
238fn ensure_phone_supported(os: &str) -> Result<()> {
239    if !matches!(os, "windows" | "macos") {
240        bail!(
241            "phone-backed identity setup requires Windows or macOS (experimental); use age-plugin-phone directly for diagnostic interoperability on other platforms"
242        );
243    }
244    Ok(())
245}
246
247async fn read_phone_computer_name() -> Option<String> {
248    #[cfg(target_os = "macos")]
249    {
250        let output = Command::new("/usr/sbin/scutil")
251            .args(["--get", "ComputerName"])
252            .stdin(Stdio::null())
253            .output()
254            .await
255            .ok()?;
256        if !output.status.success() {
257            return None;
258        }
259        Some(String::from_utf8(output.stdout).ok()?.trim_end().to_owned())
260    }
261    #[cfg(windows)]
262    {
263        std::env::var("COMPUTERNAME").ok()
264    }
265    #[cfg(not(any(windows, target_os = "macos")))]
266    {
267        None
268    }
269}
270
271fn resolve_phone_label(explicit: Option<&str>, system_label: Option<&str>) -> Result<String> {
272    let label = explicit.map(str::to_owned).unwrap_or_else(|| {
273        system_label
274            .filter(|value| !value.trim().is_empty() && value.len() <= 64)
275            .map(str::to_owned)
276            .unwrap_or_else(|| "Shine desktop".to_string())
277    });
278    if label.trim().is_empty() {
279        bail!("--label must not be empty");
280    }
281    if label.len() > 64 {
282        bail!("--label must be at most 64 UTF-8 bytes");
283    }
284    Ok(label)
285}
286
287async fn run_phone_setup(
288    program: &str,
289    recipient_type: PhoneRecipientType,
290    label: &str,
291    transport: &str,
292    adb_serial: Option<&str>,
293) -> Result<PhoneSetupResult> {
294    let mut command = Command::new(program);
295    command
296        .arg("setup")
297        .arg("--label")
298        .arg(label)
299        .arg("--transport")
300        .arg(transport)
301        .arg("--json")
302        .arg("--recipient-type")
303        .arg(recipient_type.as_str())
304        .stdin(Stdio::inherit())
305        .stdout(Stdio::piped())
306        .stderr(Stdio::inherit());
307    if let Some(serial) = adb_serial {
308        command.arg("--adb-serial").arg(serial);
309    }
310    let mut child = command
311        .spawn()
312        .with_context(|| format!("running {program} setup"))?;
313    let stdout = child
314        .stdout
315        .take()
316        .context("capturing age-plugin-phone setup result")?;
317    let (status, bytes) = tokio::join!(child.wait(), read_bounded_output(stdout));
318    let status = status.context("waiting for age-plugin-phone setup")?;
319    let bytes = bytes?;
320    if !status.success() {
321        bail!(
322            "age-plugin-phone setup failed; use a plugin version supporting --recipient-type and a matching phone app. No fallback or second setup was attempted"
323        );
324    }
325    serde_json::from_slice(&bytes).context("invalid age-plugin-phone setup result")
326}
327
328async fn read_bounded_output(mut stdout: tokio::process::ChildStdout) -> Result<Vec<u8>> {
329    let mut output = Vec::new();
330    let mut overflow = false;
331    let mut chunk = [0_u8; 4096];
332    loop {
333        let read = stdout
334            .read(&mut chunk)
335            .await
336            .context("reading age-plugin-phone setup result")?;
337        if read == 0 {
338            break;
339        }
340        if output.len().saturating_add(read) <= MAX_PHONE_SETUP_RESULT_BYTES {
341            output.extend_from_slice(&chunk[..read]);
342        } else {
343            overflow = true;
344        }
345    }
346    if overflow {
347        bail!("age-plugin-phone setup result exceeded the size limit");
348    }
349    Ok(output)
350}
351
352async fn validate_phone_setup_result(
353    result: &PhoneSetupResult,
354    recipient_type: PhoneRecipientType,
355) -> Result<()> {
356    if result.schema_version != PHONE_SETUP_RESULT_VERSION {
357        bail!(
358            "unsupported age-plugin-phone setup result version {}",
359            result.schema_version
360        );
361    }
362    validate_phone_recipient(&result.recipient, recipient_type)?;
363    if !result.identity_path.is_absolute() {
364        bail!("age-plugin-phone returned a non-absolute identity path");
365    }
366    let metadata = tokio::fs::symlink_metadata(&result.identity_path)
367        .await
368        .with_context(|| format!("reading {}", result.identity_path.display()))?;
369    if !metadata.file_type().is_file() {
370        bail!("age-plugin-phone identity stub is not a regular file");
371    }
372    let contents = tokio::fs::read_to_string(&result.identity_path)
373        .await
374        .with_context(|| format!("reading {}", result.identity_path.display()))?;
375    if !contents
376        .lines()
377        .map(str::trim)
378        .any(|line| line.starts_with("AGE-PLUGIN-PHONE-"))
379    {
380        bail!("age-plugin-phone identity stub has no phone plugin identity");
381    }
382    let recipient = extract_recipient(&result.identity_path).await?;
383    if recipient != result.recipient {
384        bail!("age-plugin-phone setup result does not match its identity stub");
385    }
386    Ok(())
387}
388
389fn validate_phone_recipient(recipient: &str, recipient_type: PhoneRecipientType) -> Result<()> {
390    let (hrp, data, variant) = bech32::decode(recipient)
391        .context("age-plugin-phone returned an invalid Bech32 recipient")?;
392    let expected = match recipient_type {
393        PhoneRecipientType::Tag => "age1tag",
394        PhoneRecipientType::Phone => "age1phone",
395    };
396    if hrp != expected || variant != Variant::Bech32 {
397        bail!("age-plugin-phone returned a recipient that does not match the requested type");
398    }
399    let bytes = Vec::<u8>::from_base32(&data).context("invalid phone recipient payload")?;
400    if bech32::encode(&hrp, data, variant)? != recipient {
401        bail!("age-plugin-phone returned a non-canonical recipient");
402    }
403    if bytes.is_empty()
404        || (recipient_type == PhoneRecipientType::Tag
405            && (bytes.len() != 33 || !matches!(bytes[0], 2 | 3)))
406    {
407        bail!("age-plugin-phone returned an invalid recipient public-key payload");
408    }
409    Ok(())
410}
411
412fn add_age_identity_path(config: &mut Config, path: &Path, value: String) {
413    if config.age_identity.is_none() && config.age_identities.is_empty() {
414        let implicit = config
415            .resolved_age_identities()
416            .into_iter()
417            .filter_map(|existing| existing.to_str().map(str::to_owned))
418            .collect::<Vec<_>>();
419        config.age_identities.extend(implicit);
420    }
421    if !config
422        .resolved_age_identities()
423        .iter()
424        .any(|item| item == path)
425    {
426        config.age_identities.push(value);
427    }
428}
429
430fn ensure_touch_id_supported(touch_id: bool, os: &str) -> Result<()> {
431    if touch_id && os != "macos" {
432        bail!(
433            "Secure Enclave identities require macOS; run `shine env secret identity init` without \
434             --touch-id to generate a plain age identity"
435        );
436    }
437    Ok(())
438}
439
440fn validate_access_control(value: &str) -> Result<()> {
441    if !VALID_ACCESS_CONTROLS.contains(&value) {
442        bail!(
443            "unknown --access-control \"{value}\"; expected one of: {}",
444            VALID_ACCESS_CONTROLS.join(", ")
445        );
446    }
447    Ok(())
448}
449
450fn default_identity_path(config: &Config) -> PathBuf {
451    config.shine_dir().join("age").join("identity.txt")
452}
453
454async fn run_keygen(program: &str, args: &[String]) -> Result<()> {
455    let status = Command::new(program)
456        .args(args)
457        .status()
458        .await
459        .with_context(|| format!("running {program}"))?;
460    if !status.success() {
461        bail!("{program} failed");
462    }
463    Ok(())
464}
465
466#[cfg(unix)]
467async fn set_owner_only_permissions(path: &Path) -> Result<()> {
468    use std::os::unix::fs::PermissionsExt;
469    let permissions = std::fs::Permissions::from_mode(0o600);
470    tokio::fs::set_permissions(path, permissions)
471        .await
472        .with_context(|| format!("setting permissions on {}", path.display()))
473}
474
475/// Extract an `age1...` recipient from an identity file's leading comment, as
476/// written by native keygen and supported hardware plugins.
477async fn extract_recipient(path: &Path) -> Result<String> {
478    let contents = tokio::fs::read_to_string(path)
479        .await
480        .with_context(|| format!("reading {}", path.display()))?;
481    contents
482        .lines()
483        .filter_map(|line| line.strip_prefix('#'))
484        .map(str::trim)
485        .find_map(|line| {
486            line.split_whitespace()
487                .find(|token| token.starts_with("age1"))
488        })
489        .map(str::to_string)
490        .with_context(|| format!("no recipient found in {}", path.display()))
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn touch_id_requires_macos() {
499        let err = ensure_touch_id_supported(true, "linux").unwrap_err();
500        assert!(err.to_string().contains("require macOS"), "{err:#}");
501    }
502
503    #[test]
504    fn touch_id_allowed_on_macos() {
505        assert!(ensure_touch_id_supported(true, "macos").is_ok());
506    }
507
508    #[test]
509    fn non_touch_id_allowed_on_any_os() {
510        assert!(ensure_touch_id_supported(false, "linux").is_ok());
511        assert!(ensure_touch_id_supported(false, "windows").is_ok());
512    }
513
514    const TAG: &str = "age1tag1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwgc25f05";
515    const PHONE: &str = "age1phone1qypkk9737tsjcsj8lz7wdetr53q0yacr0kqjm6en5r62zw29mzvv99sa27n9c";
516
517    #[test]
518    fn phone_recipient_validation_checks_type_encoding_and_tag_structure() {
519        use bech32::ToBase32;
520        validate_phone_recipient(TAG, PhoneRecipientType::Tag).unwrap();
521        validate_phone_recipient(PHONE, PhoneRecipientType::Phone).unwrap();
522        for (recipient, kind) in [
523            (PHONE, PhoneRecipientType::Tag),
524            (TAG, PhoneRecipientType::Phone),
525            ("age1tag1invalid", PhoneRecipientType::Tag),
526            ("age1phone1invalid", PhoneRecipientType::Phone),
527            (&TAG.to_uppercase(), PhoneRecipientType::Tag),
528        ] {
529            assert!(validate_phone_recipient(recipient, kind).is_err());
530        }
531        for bytes in [vec![], vec![2; 32], vec![2; 34], vec![4; 33]] {
532            let invalid = bech32::encode("age1tag", bytes.to_base32(), Variant::Bech32).unwrap();
533            assert!(validate_phone_recipient(&invalid, PhoneRecipientType::Tag).is_err());
534        }
535        let wrong_variant =
536            bech32::encode("age1tag", vec![2; 33].to_base32(), Variant::Bech32m).unwrap();
537        assert!(validate_phone_recipient(&wrong_variant, PhoneRecipientType::Tag).is_err());
538    }
539
540    #[tokio::test]
541    async fn tagged_phone_setup_keeps_the_phone_identity_stub() {
542        let dir = std::env::temp_dir().join(format!("shine-phone-test-{}", uuid::Uuid::new_v4()));
543        tokio::fs::create_dir_all(&dir).await.unwrap();
544        let path = dir.join("phone.txt");
545        let content = format!("# recipient: {TAG}\nAGE-PLUGIN-PHONE-1EXAMPLE\n");
546        tokio::fs::write(&path, &content).await.unwrap();
547        let result = PhoneSetupResult {
548            schema_version: 1,
549            identity_path: path.clone(),
550            recipient: TAG.into(),
551        };
552        validate_phone_setup_result(&result, PhoneRecipientType::Tag)
553            .await
554            .unwrap();
555        assert!(
556            validate_phone_setup_result(&result, PhoneRecipientType::Phone)
557                .await
558                .is_err()
559        );
560        assert_eq!(extract_recipient(&path).await.unwrap(), TAG);
561        assert_eq!(tokio::fs::read_to_string(path).await.unwrap(), content);
562        tokio::fs::remove_dir_all(dir).await.unwrap();
563    }
564
565    #[test]
566    fn phone_setup_allows_windows_and_macos_only() {
567        assert!(ensure_phone_supported("windows").is_ok());
568        assert!(ensure_phone_supported("macos").is_ok());
569        for os in ["linux", "unknown"] {
570            let err = ensure_phone_supported(os).unwrap_err();
571            assert!(err.to_string().contains("Windows or macOS"), "{err:#}");
572        }
573    }
574
575    #[test]
576    fn explicit_phone_label_uses_the_plugin_byte_limit() {
577        assert_eq!(
578            resolve_phone_label(Some("Work laptop"), Some("System name")).unwrap(),
579            "Work laptop"
580        );
581        assert!(resolve_phone_label(Some(" "), Some("System name")).is_err());
582        assert!(resolve_phone_label(Some(&"桌".repeat(22)), None).is_err());
583        assert!(resolve_phone_label(Some(&"a".repeat(64)), None).is_ok());
584    }
585
586    #[test]
587    fn phone_label_uses_a_valid_system_name_or_fallback() {
588        for name in ["Work Mac", "工作电脑", &"a".repeat(64)] {
589            assert_eq!(resolve_phone_label(None, Some(name)).unwrap(), name);
590        }
591        for name in [
592            None,
593            Some(""),
594            Some("  "),
595            Some(&"桌".repeat(22)),
596            Some(&format!(" {} ", "a".repeat(64))),
597        ] {
598            assert_eq!(resolve_phone_label(None, name).unwrap(), "Shine desktop");
599        }
600    }
601
602    #[test]
603    fn access_control_validates_known_values() {
604        for value in VALID_ACCESS_CONTROLS {
605            assert!(validate_access_control(value).is_ok());
606        }
607        let err = validate_access_control("bogus").unwrap_err();
608        assert!(
609            err.to_string().contains("unknown --access-control"),
610            "{err:#}"
611        );
612    }
613
614    #[test]
615    fn touch_id_keygen_requests_native_tagged_recipient() {
616        let args = touch_id_keygen_args("any-biometry", Path::new("identity.txt"));
617        assert_eq!(
618            args,
619            [
620                "keygen",
621                "--recipient-type=tag",
622                "--access-control=any-biometry",
623                "-o",
624                "identity.txt",
625            ]
626        );
627    }
628
629    #[tokio::test]
630    async fn extracts_recipient_from_identity_comment() {
631        let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
632        tokio::fs::create_dir_all(&dir).await.unwrap();
633        let path = dir.join("identity.txt");
634        tokio::fs::write(
635            &path,
636            "# created: 2026-01-01\n# public key: age1qexampleexampleexample\nAGE-SECRET-KEY-1EXAMPLE\n",
637        )
638        .await
639        .unwrap();
640
641        let recipient = extract_recipient(&path).await.unwrap();
642        assert_eq!(recipient, "age1qexampleexampleexample");
643
644        tokio::fs::remove_dir_all(&dir).await.unwrap();
645    }
646
647    #[tokio::test]
648    async fn extract_recipient_errors_without_recipient_comment() {
649        let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
650        tokio::fs::create_dir_all(&dir).await.unwrap();
651        let path = dir.join("identity.txt");
652        tokio::fs::write(&path, "AGE-SECRET-KEY-1EXAMPLE\n")
653            .await
654            .unwrap();
655
656        let err = extract_recipient(&path).await.unwrap_err();
657        assert!(err.to_string().contains("no recipient found"), "{err:#}");
658
659        tokio::fs::remove_dir_all(&dir).await.unwrap();
660    }
661
662    #[tokio::test]
663    async fn validates_matching_phone_setup_result() {
664        let dir =
665            std::env::temp_dir().join(format!("shine-phone-identity-{}", uuid::Uuid::new_v4()));
666        tokio::fs::create_dir_all(&dir).await.unwrap();
667        let path = dir.join("identity.txt");
668        tokio::fs::write(
669            &path,
670            "# public age-plugin-phone identity stub\n# recipient: age1phone1qypkk9737tsjcsj8lz7wdetr53q0yacr0kqjm6en5r62zw29mzvv99sa27n9c\nAGE-PLUGIN-PHONE-1EXAMPLE\n",
671        )
672        .await
673        .unwrap();
674        let result = PhoneSetupResult {
675            schema_version: PHONE_SETUP_RESULT_VERSION,
676            identity_path: path,
677            recipient: "age1phone1qypkk9737tsjcsj8lz7wdetr53q0yacr0kqjm6en5r62zw29mzvv99sa27n9c"
678                .to_string(),
679        };
680
681        validate_phone_setup_result(&result, PhoneRecipientType::Phone)
682            .await
683            .unwrap();
684        tokio::fs::remove_dir_all(&dir).await.unwrap();
685    }
686
687    #[tokio::test]
688    async fn rejects_phone_setup_result_that_disagrees_with_stub() {
689        let dir =
690            std::env::temp_dir().join(format!("shine-phone-identity-{}", uuid::Uuid::new_v4()));
691        tokio::fs::create_dir_all(&dir).await.unwrap();
692        let path = dir.join("identity.txt");
693        tokio::fs::write(
694            &path,
695            "# recipient: age1phone1actual\nAGE-PLUGIN-PHONE-1EXAMPLE\n",
696        )
697        .await
698        .unwrap();
699        let result = PhoneSetupResult {
700            schema_version: PHONE_SETUP_RESULT_VERSION,
701            identity_path: path,
702            recipient: "age1phone1qypkk9737tsjcsj8lz7wdetr53q0yacr0kqjm6en5r62zw29mzvv99sa27n9c"
703                .to_string(),
704        };
705
706        let err = validate_phone_setup_result(&result, PhoneRecipientType::Phone)
707            .await
708            .unwrap_err();
709        assert!(err.to_string().contains("does not match"), "{err:#}");
710        tokio::fs::remove_dir_all(&dir).await.unwrap();
711    }
712
713    #[cfg(unix)]
714    #[tokio::test]
715    async fn phone_setup_uses_the_versioned_plugin_handoff() {
716        use std::os::unix::fs::PermissionsExt as _;
717
718        let dir =
719            std::env::temp_dir().join(format!("shine-phone-command-{}", uuid::Uuid::new_v4()));
720        tokio::fs::create_dir_all(&dir).await.unwrap();
721        let program = dir.join("fake-age-plugin-phone");
722        for (kind, recipient) in [
723            (PhoneRecipientType::Tag, TAG),
724            (PhoneRecipientType::Phone, PHONE),
725        ] {
726            let result_json = serde_json::json!({"schema_version": 1, "identity_path": "/tmp/phone-identity.txt", "recipient": recipient});
727            let script = format!(
728                r#"#!/bin/sh
729[ "$1" = setup ] && [ "$2" = --label ] && [ "$3" = 'Work laptop' ] || exit 11
730[ "$4" = --transport ] && [ "$5" = qr ] && [ "$6" = --json ] || exit 12
731[ "$7" = --recipient-type ] && [ "$8" = {} ] || exit 13
732[ "$9" = --adb-serial ] && [ "${{10}}" = 'device serial' ] && [ "$#" = 10 ] || exit 14
733printf '%s\n' '{}'
734"#,
735                kind.as_str(),
736                result_json
737            );
738            tokio::fs::write(&program, script).await.unwrap();
739            tokio::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700))
740                .await
741                .unwrap();
742            let result = run_phone_setup(
743                program.to_str().unwrap(),
744                kind,
745                "Work laptop",
746                "qr",
747                Some("device serial"),
748            )
749            .await
750            .unwrap();
751            assert_eq!(result.schema_version, PHONE_SETUP_RESULT_VERSION);
752            assert_eq!(
753                result.identity_path,
754                PathBuf::from("/tmp/phone-identity.txt")
755            );
756            assert_eq!(result.recipient, recipient);
757        }
758        tokio::fs::remove_dir_all(&dir).await.unwrap();
759    }
760
761    #[cfg(unix)]
762    #[tokio::test]
763    async fn unsupported_phone_setup_never_retries() {
764        use std::os::unix::fs::PermissionsExt;
765        let dir = std::env::temp_dir().join(format!("shine-phone-test-{}", uuid::Uuid::new_v4()));
766        tokio::fs::create_dir_all(&dir).await.unwrap();
767        let program = dir.join("old-plugin");
768        let calls = dir.join("calls");
769        tokio::fs::write(
770            &program,
771            format!("#!/bin/sh\nprintf x >> '{}'\nexit 2\n", calls.display()),
772        )
773        .await
774        .unwrap();
775        tokio::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700))
776            .await
777            .unwrap();
778        let err = run_phone_setup(
779            program.to_str().unwrap(),
780            PhoneRecipientType::Tag,
781            "test",
782            "auto",
783            None,
784        )
785        .await
786        .unwrap_err();
787        assert!(err.to_string().contains("--recipient-type"));
788        assert_eq!(tokio::fs::read_to_string(calls).await.unwrap(), "x");
789        tokio::fs::remove_dir_all(dir).await.unwrap();
790    }
791
792    #[tokio::test]
793    async fn adding_phone_identity_preserves_implicit_default_identity() {
794        let dir =
795            std::env::temp_dir().join(format!("shine-phone-identity-{}", uuid::Uuid::new_v4()));
796        let mut config = Config::new_for_test(&dir);
797        let default_path = dir.join("age").join("identity.txt");
798        let phone_path = dir.join("phone.txt");
799        tokio::fs::create_dir_all(default_path.parent().unwrap())
800            .await
801            .unwrap();
802        tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
803            .await
804            .unwrap();
805
806        add_age_identity_path(
807            &mut config,
808            &phone_path,
809            phone_path.to_string_lossy().into_owned(),
810        );
811        assert_eq!(
812            config.resolved_age_identities(),
813            vec![default_path, phone_path]
814        );
815        tokio::fs::remove_dir_all(&dir).await.unwrap();
816    }
817
818    #[test]
819    fn default_identity_path_is_under_shine_dir() {
820        let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
821        let config = Config::new_for_test(&dir);
822
823        assert_eq!(
824            default_identity_path(&config),
825            dir.join("age").join("identity.txt")
826        );
827    }
828}