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 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
404/// Extract an `age1...` recipient from an identity file's leading comment, as
405/// written by native keygen and supported hardware plugins.
406async 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}