1use 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
475async 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}