1use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use tokio::io::AsyncReadExt;
10use tokio::process::Command;
11
12use crate::config::Config;
13use crate::proc::ensure_command;
14use crate::{colors, path_display};
15
16const DEFAULT_ACCESS_CONTROL: &str = "any-biometry";
17const VALID_ACCESS_CONTROLS: &[&str] = &[
18 "any-biometry",
19 "any-biometry-or-passcode",
20 "current-biometry",
21 "passcode",
22];
23const PHONE_SETUP_RESULT_VERSION: u16 = 1;
24const MAX_PHONE_SETUP_RESULT_BYTES: usize = 16 * 1024;
25
26#[derive(Debug, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct PhoneSetupResult {
29 schema_version: u16,
30 identity_path: PathBuf,
31 recipient: String,
32}
33
34#[derive(Serialize)]
35struct ManualAgeIdentities<'a> {
36 age_identities: &'a [String],
37}
38
39pub async fn handle_phone_identity_init(
40 config: &Config,
41 label: Option<&str>,
42 transport: &str,
43 adb_serial: Option<&str>,
44) -> Result<()> {
45 ensure_phone_supported(std::env::consts::OS)?;
46 if config.project_overrides_age_identities() {
47 bail!(
48 "the active project explicitly overrides age identity configuration; remove or update that project override before pairing a phone-backed identity"
49 );
50 }
51 ensure_command("age-plugin-phone")?;
52 let label = resolve_phone_label(label)?;
53 let result = run_phone_setup("age-plugin-phone", &label, transport, adb_serial).await?;
54 validate_phone_setup_result(&result).await?;
55
56 let identity_value = result
57 .identity_path
58 .to_str()
59 .context("phone identity path is not valid Unicode")?
60 .to_owned();
61 let mut global = Config::load_global_runtime_for_dry_run().await?;
62 add_age_identity_path(&mut global, &result.identity_path, identity_value);
63 if let Err(error) = global.save().await {
64 let manual = toml::to_string(&ManualAgeIdentities {
65 age_identities: &global.age_identities,
66 })
67 .unwrap_or_else(|_| "age_identities = [\"<phone identity path>\"]\n".to_string());
68 eprintln!(
69 "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{}",
70 path_display::format(global.config_path()),
71 manual.trim_end()
72 );
73 return Err(error).context("saving the phone identity in global Shine config");
74 }
75
76 println!(
77 "{}",
78 colors::green(&format!(
79 "configured phone-backed age identity at {}",
80 path_display::format(&result.identity_path)
81 ))
82 );
83 println!(" recipient: {}", result.recipient);
84 println!(
85 " global config: {}",
86 path_display::format(global.config_path())
87 );
88 println!();
89 println!(
90 "{}",
91 colors::dim(
92 "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."
93 )
94 );
95 if global.secret_backend.as_deref() != Some("age") {
96 println!(
97 "{}",
98 colors::dim(
99 "The global secret_backend was not changed. Set secret_backend = \"age\" explicitly if age should become the default for encrypt/seal."
100 )
101 );
102 }
103 Ok(())
104}
105
106pub async fn handle_identity_init(
107 config: &Config,
108 touch_id: bool,
109 access_control: Option<&str>,
110 output: Option<&Path>,
111 force: bool,
112) -> Result<()> {
113 ensure_touch_id_supported(touch_id, std::env::consts::OS)?;
114 if !touch_id && access_control.is_some() {
115 bail!("--access-control only applies with --touch-id");
116 }
117 let access_control = access_control.unwrap_or(DEFAULT_ACCESS_CONTROL);
118 if touch_id {
119 validate_access_control(access_control)?;
120 }
121
122 let output_path = output
123 .map(Path::to_path_buf)
124 .unwrap_or_else(|| default_identity_path(config));
125 if output_path.exists() && !force {
126 bail!(
127 "{} already exists; pass --force to overwrite",
128 output_path.display()
129 );
130 }
131 if let Some(parent) = output_path.parent() {
132 tokio::fs::create_dir_all(parent)
133 .await
134 .with_context(|| format!("creating {}", parent.display()))?;
135 }
136
137 if touch_id {
138 ensure_command("age-plugin-se")?;
139 run_keygen(
140 "age-plugin-se",
141 &[
142 "keygen".to_string(),
143 format!("--access-control={access_control}"),
144 "-o".to_string(),
145 output_path.to_string_lossy().into_owned(),
146 ],
147 )
148 .await?;
149 } else {
150 ensure_command("age-keygen")?;
151 run_keygen(
152 "age-keygen",
153 &["-o".to_string(), output_path.to_string_lossy().into_owned()],
154 )
155 .await?;
156 }
157
158 #[cfg(unix)]
159 set_owner_only_permissions(&output_path).await?;
160
161 let recipient = extract_recipient(&output_path).await?;
162 println!(
163 "{}",
164 colors::green(&format!(
165 "generated age identity at {}",
166 path_display::format(&output_path)
167 ))
168 );
169 println!(" recipient: {recipient}");
170 println!();
171 println!(
172 "{}",
173 colors::dim(
174 "Add this recipient to age_recipients in config.toml (or [env.encryption] in \
175 shine.workspace.toml) so others can decrypt secrets sealed for it."
176 )
177 );
178 if config.secret_backend.as_deref() != Some("age") {
179 println!(
180 "{}",
181 colors::dim(
182 "Set secret_backend = \"age\" in config.toml to make age the default for \
183 `shine env secret encrypt`/`shine env secret seal`."
184 )
185 );
186 }
187 if config.age_identity.is_none() && output_path != default_identity_path(config) {
188 println!(
189 "{}",
190 colors::dim(&format!(
191 "Set age_identity = \"{}\" in config.toml so shine can find this identity.",
192 output_path.display()
193 ))
194 );
195 }
196 Ok(())
197}
198
199pub async fn handle_identity_list(config: &Config) -> Result<()> {
200 let identities = config.resolved_age_identities();
201 if identities.is_empty() {
202 println!(
203 "{}",
204 colors::dim("No age identity configured. Run `shine env secret identity init`.")
205 );
206 return Ok(());
207 }
208 for identity in &identities {
209 let recipient = extract_recipient(identity).await?;
210 println!("{} {}", path_display::format(identity), recipient);
211 }
212 Ok(())
213}
214
215fn ensure_phone_supported(os: &str) -> Result<()> {
216 if os != "windows" {
217 bail!(
218 "phone-backed identity setup currently requires the Windows Alpha platform; use age-plugin-phone directly for diagnostic interoperability on other platforms"
219 );
220 }
221 Ok(())
222}
223
224fn resolve_phone_label(explicit: Option<&str>) -> Result<String> {
225 let label = explicit.map(str::to_owned).unwrap_or_else(|| {
226 std::env::var("COMPUTERNAME")
227 .ok()
228 .filter(|value| {
229 let trimmed = value.trim();
230 !trimmed.is_empty() && trimmed.len() <= 64
231 })
232 .unwrap_or_else(|| "Shine desktop".to_string())
233 });
234 if label.trim().is_empty() {
235 bail!("--label must not be empty");
236 }
237 if label.len() > 64 {
238 bail!("--label must be at most 64 UTF-8 bytes");
239 }
240 Ok(label)
241}
242
243async fn run_phone_setup(
244 program: &str,
245 label: &str,
246 transport: &str,
247 adb_serial: Option<&str>,
248) -> Result<PhoneSetupResult> {
249 let mut command = Command::new(program);
250 command
251 .arg("setup")
252 .arg("--label")
253 .arg(label)
254 .arg("--transport")
255 .arg(transport)
256 .arg("--json")
257 .stdin(Stdio::inherit())
258 .stdout(Stdio::piped())
259 .stderr(Stdio::inherit());
260 if let Some(serial) = adb_serial {
261 command.arg("--adb-serial").arg(serial);
262 }
263 let mut child = command
264 .spawn()
265 .with_context(|| format!("running {program} setup"))?;
266 let stdout = child
267 .stdout
268 .take()
269 .context("capturing age-plugin-phone setup result")?;
270 let (status, bytes) = tokio::join!(child.wait(), read_bounded_output(stdout));
271 let status = status.context("waiting for age-plugin-phone setup")?;
272 let bytes = bytes?;
273 if !status.success() {
274 bail!("age-plugin-phone setup failed");
275 }
276 serde_json::from_slice(&bytes).context("invalid age-plugin-phone setup result")
277}
278
279async fn read_bounded_output(mut stdout: tokio::process::ChildStdout) -> Result<Vec<u8>> {
280 let mut output = Vec::new();
281 let mut overflow = false;
282 let mut chunk = [0_u8; 4096];
283 loop {
284 let read = stdout
285 .read(&mut chunk)
286 .await
287 .context("reading age-plugin-phone setup result")?;
288 if read == 0 {
289 break;
290 }
291 if output.len().saturating_add(read) <= MAX_PHONE_SETUP_RESULT_BYTES {
292 output.extend_from_slice(&chunk[..read]);
293 } else {
294 overflow = true;
295 }
296 }
297 if overflow {
298 bail!("age-plugin-phone setup result exceeded the size limit");
299 }
300 Ok(output)
301}
302
303async fn validate_phone_setup_result(result: &PhoneSetupResult) -> Result<()> {
304 if result.schema_version != PHONE_SETUP_RESULT_VERSION {
305 bail!(
306 "unsupported age-plugin-phone setup result version {}",
307 result.schema_version
308 );
309 }
310 if !result.recipient.starts_with("age1phone")
311 || result.recipient.chars().any(char::is_whitespace)
312 {
313 bail!("age-plugin-phone returned an invalid recipient");
314 }
315 if !result.identity_path.is_absolute() {
316 bail!("age-plugin-phone returned a non-absolute identity path");
317 }
318 let metadata = tokio::fs::symlink_metadata(&result.identity_path)
319 .await
320 .with_context(|| format!("reading {}", result.identity_path.display()))?;
321 if !metadata.file_type().is_file() {
322 bail!("age-plugin-phone identity stub is not a regular file");
323 }
324 let contents = tokio::fs::read_to_string(&result.identity_path)
325 .await
326 .with_context(|| format!("reading {}", result.identity_path.display()))?;
327 if !contents
328 .lines()
329 .map(str::trim)
330 .any(|line| line.starts_with("AGE-PLUGIN-PHONE-"))
331 {
332 bail!("age-plugin-phone identity stub has no phone plugin identity");
333 }
334 let recipient = extract_recipient(&result.identity_path).await?;
335 if recipient != result.recipient {
336 bail!("age-plugin-phone setup result does not match its identity stub");
337 }
338 Ok(())
339}
340
341fn add_age_identity_path(config: &mut Config, path: &Path, value: String) {
342 if config.age_identity.is_none() && config.age_identities.is_empty() {
343 let implicit = config
344 .resolved_age_identities()
345 .into_iter()
346 .filter_map(|existing| existing.to_str().map(str::to_owned))
347 .collect::<Vec<_>>();
348 config.age_identities.extend(implicit);
349 }
350 if !config
351 .resolved_age_identities()
352 .iter()
353 .any(|item| item == path)
354 {
355 config.age_identities.push(value);
356 }
357}
358
359fn ensure_touch_id_supported(touch_id: bool, os: &str) -> Result<()> {
360 if touch_id && os != "macos" {
361 bail!(
362 "Secure Enclave identities require macOS; run `shine env secret identity init` without \
363 --touch-id to generate a plain age identity"
364 );
365 }
366 Ok(())
367}
368
369fn validate_access_control(value: &str) -> Result<()> {
370 if !VALID_ACCESS_CONTROLS.contains(&value) {
371 bail!(
372 "unknown --access-control \"{value}\"; expected one of: {}",
373 VALID_ACCESS_CONTROLS.join(", ")
374 );
375 }
376 Ok(())
377}
378
379fn default_identity_path(config: &Config) -> PathBuf {
380 config.shine_dir().join("age").join("identity.txt")
381}
382
383async fn run_keygen(program: &str, args: &[String]) -> Result<()> {
384 let status = Command::new(program)
385 .args(args)
386 .status()
387 .await
388 .with_context(|| format!("running {program}"))?;
389 if !status.success() {
390 bail!("{program} failed");
391 }
392 Ok(())
393}
394
395#[cfg(unix)]
396async fn set_owner_only_permissions(path: &Path) -> Result<()> {
397 use std::os::unix::fs::PermissionsExt;
398 let permissions = std::fs::Permissions::from_mode(0o600);
399 tokio::fs::set_permissions(path, permissions)
400 .await
401 .with_context(|| format!("setting permissions on {}", path.display()))
402}
403
404async fn extract_recipient(path: &Path) -> Result<String> {
407 let contents = tokio::fs::read_to_string(path)
408 .await
409 .with_context(|| format!("reading {}", path.display()))?;
410 contents
411 .lines()
412 .filter_map(|line| line.strip_prefix('#'))
413 .map(str::trim)
414 .find_map(|line| {
415 line.split_whitespace()
416 .find(|token| token.starts_with("age1"))
417 })
418 .map(str::to_string)
419 .with_context(|| format!("no recipient found in {}", path.display()))
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn touch_id_requires_macos() {
428 let err = ensure_touch_id_supported(true, "linux").unwrap_err();
429 assert!(err.to_string().contains("require macOS"), "{err:#}");
430 }
431
432 #[test]
433 fn touch_id_allowed_on_macos() {
434 assert!(ensure_touch_id_supported(true, "macos").is_ok());
435 }
436
437 #[test]
438 fn non_touch_id_allowed_on_any_os() {
439 assert!(ensure_touch_id_supported(false, "linux").is_ok());
440 assert!(ensure_touch_id_supported(false, "windows").is_ok());
441 }
442
443 #[test]
444 fn phone_setup_requires_windows() {
445 assert!(ensure_phone_supported("windows").is_ok());
446 let err = ensure_phone_supported("macos").unwrap_err();
447 assert!(err.to_string().contains("Windows Alpha"), "{err:#}");
448 }
449
450 #[test]
451 fn explicit_phone_label_uses_the_plugin_byte_limit() {
452 assert_eq!(
453 resolve_phone_label(Some("Work laptop")).unwrap(),
454 "Work laptop"
455 );
456 assert!(resolve_phone_label(Some(" ")).is_err());
457 assert!(resolve_phone_label(Some(&"桌".repeat(22))).is_err());
458 }
459
460 #[test]
461 fn access_control_validates_known_values() {
462 for value in VALID_ACCESS_CONTROLS {
463 assert!(validate_access_control(value).is_ok());
464 }
465 let err = validate_access_control("bogus").unwrap_err();
466 assert!(
467 err.to_string().contains("unknown --access-control"),
468 "{err:#}"
469 );
470 }
471
472 #[tokio::test]
473 async fn extracts_recipient_from_identity_comment() {
474 let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
475 tokio::fs::create_dir_all(&dir).await.unwrap();
476 let path = dir.join("identity.txt");
477 tokio::fs::write(
478 &path,
479 "# created: 2026-01-01\n# public key: age1qexampleexampleexample\nAGE-SECRET-KEY-1EXAMPLE\n",
480 )
481 .await
482 .unwrap();
483
484 let recipient = extract_recipient(&path).await.unwrap();
485 assert_eq!(recipient, "age1qexampleexampleexample");
486
487 tokio::fs::remove_dir_all(&dir).await.unwrap();
488 }
489
490 #[tokio::test]
491 async fn extract_recipient_errors_without_recipient_comment() {
492 let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
493 tokio::fs::create_dir_all(&dir).await.unwrap();
494 let path = dir.join("identity.txt");
495 tokio::fs::write(&path, "AGE-SECRET-KEY-1EXAMPLE\n")
496 .await
497 .unwrap();
498
499 let err = extract_recipient(&path).await.unwrap_err();
500 assert!(err.to_string().contains("no recipient found"), "{err:#}");
501
502 tokio::fs::remove_dir_all(&dir).await.unwrap();
503 }
504
505 #[tokio::test]
506 async fn validates_matching_phone_setup_result() {
507 let dir =
508 std::env::temp_dir().join(format!("shine-phone-identity-{}", uuid::Uuid::new_v4()));
509 tokio::fs::create_dir_all(&dir).await.unwrap();
510 let path = dir.join("identity.txt");
511 tokio::fs::write(
512 &path,
513 "# public age-plugin-phone identity stub\n# recipient: age1phone1example\nAGE-PLUGIN-PHONE-1EXAMPLE\n",
514 )
515 .await
516 .unwrap();
517 let result = PhoneSetupResult {
518 schema_version: PHONE_SETUP_RESULT_VERSION,
519 identity_path: path,
520 recipient: "age1phone1example".to_string(),
521 };
522
523 validate_phone_setup_result(&result).await.unwrap();
524 tokio::fs::remove_dir_all(&dir).await.unwrap();
525 }
526
527 #[tokio::test]
528 async fn rejects_phone_setup_result_that_disagrees_with_stub() {
529 let dir =
530 std::env::temp_dir().join(format!("shine-phone-identity-{}", uuid::Uuid::new_v4()));
531 tokio::fs::create_dir_all(&dir).await.unwrap();
532 let path = dir.join("identity.txt");
533 tokio::fs::write(
534 &path,
535 "# recipient: age1phone1actual\nAGE-PLUGIN-PHONE-1EXAMPLE\n",
536 )
537 .await
538 .unwrap();
539 let result = PhoneSetupResult {
540 schema_version: PHONE_SETUP_RESULT_VERSION,
541 identity_path: path,
542 recipient: "age1phone1different".to_string(),
543 };
544
545 let err = validate_phone_setup_result(&result).await.unwrap_err();
546 assert!(err.to_string().contains("does not match"), "{err:#}");
547 tokio::fs::remove_dir_all(&dir).await.unwrap();
548 }
549
550 #[cfg(unix)]
551 #[tokio::test]
552 async fn phone_setup_uses_the_versioned_plugin_handoff() {
553 use std::os::unix::fs::PermissionsExt as _;
554
555 let dir =
556 std::env::temp_dir().join(format!("shine-phone-command-{}", uuid::Uuid::new_v4()));
557 tokio::fs::create_dir_all(&dir).await.unwrap();
558 let program = dir.join("fake-age-plugin-phone");
559 tokio::fs::write(
560 &program,
561 concat!(
562 "#!/bin/sh\n",
563 "test \"$1\" = setup || exit 11\n",
564 "test \"$2\" = --label || exit 12\n",
565 "test \"$3\" = 'Work laptop' || exit 13\n",
566 "test \"$4\" = --transport || exit 14\n",
567 "test \"$5\" = qr || exit 15\n",
568 "test \"$6\" = --json || exit 16\n",
569 "test \"$#\" = 6 || exit 17\n",
570 "printf '%s\\n' '{\"schema_version\":1,\"identity_path\":\"/tmp/phone-identity.txt\",\"recipient\":\"age1phone1example\"}'\n",
571 ),
572 )
573 .await
574 .unwrap();
575 let mut permissions = tokio::fs::metadata(&program).await.unwrap().permissions();
576 permissions.set_mode(0o700);
577 tokio::fs::set_permissions(&program, permissions)
578 .await
579 .unwrap();
580
581 let result = run_phone_setup(program.to_str().unwrap(), "Work laptop", "qr", None)
582 .await
583 .unwrap();
584 assert_eq!(result.schema_version, PHONE_SETUP_RESULT_VERSION);
585 assert_eq!(
586 result.identity_path,
587 PathBuf::from("/tmp/phone-identity.txt")
588 );
589 assert_eq!(result.recipient, "age1phone1example");
590
591 tokio::fs::remove_dir_all(&dir).await.unwrap();
592 }
593
594 #[tokio::test]
595 async fn adding_phone_identity_preserves_implicit_default_identity() {
596 let dir =
597 std::env::temp_dir().join(format!("shine-phone-identity-{}", uuid::Uuid::new_v4()));
598 let mut config = Config::new_for_test(&dir);
599 let default_path = dir.join("age").join("identity.txt");
600 let phone_path = dir.join("phone.txt");
601 tokio::fs::create_dir_all(default_path.parent().unwrap())
602 .await
603 .unwrap();
604 tokio::fs::write(&default_path, "AGE-SECRET-KEY-1EXAMPLE\n")
605 .await
606 .unwrap();
607
608 add_age_identity_path(
609 &mut config,
610 &phone_path,
611 phone_path.to_string_lossy().into_owned(),
612 );
613 assert_eq!(
614 config.resolved_age_identities(),
615 vec![default_path, phone_path]
616 );
617 tokio::fs::remove_dir_all(&dir).await.unwrap();
618 }
619
620 #[test]
621 fn default_identity_path_is_under_shine_dir() {
622 let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
623 let config = Config::new_for_test(&dir);
624
625 assert_eq!(
626 default_identity_path(&config),
627 dir.join("age").join("identity.txt")
628 );
629 }
630}