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`.
4
5use anyhow::{Context, Result, bail};
6use std::path::{Path, PathBuf};
7use tokio::process::Command;
8
9use crate::config::Config;
10use crate::proc::ensure_command;
11use crate::{colors, path_display};
12
13const DEFAULT_ACCESS_CONTROL: &str = "any-biometry";
14const VALID_ACCESS_CONTROLS: &[&str] = &[
15    "any-biometry",
16    "any-biometry-or-passcode",
17    "current-biometry",
18    "passcode",
19];
20
21pub async fn handle_identity_init(
22    config: &Config,
23    touch_id: bool,
24    access_control: Option<&str>,
25    output: Option<&Path>,
26    force: bool,
27) -> Result<()> {
28    ensure_touch_id_supported(touch_id, std::env::consts::OS)?;
29    if !touch_id && access_control.is_some() {
30        bail!("--access-control only applies with --touch-id");
31    }
32    let access_control = access_control.unwrap_or(DEFAULT_ACCESS_CONTROL);
33    if touch_id {
34        validate_access_control(access_control)?;
35    }
36
37    let output_path = output
38        .map(Path::to_path_buf)
39        .unwrap_or_else(|| default_identity_path(config));
40    if output_path.exists() && !force {
41        bail!(
42            "{} already exists; pass --force to overwrite",
43            output_path.display()
44        );
45    }
46    if let Some(parent) = output_path.parent() {
47        tokio::fs::create_dir_all(parent)
48            .await
49            .with_context(|| format!("creating {}", parent.display()))?;
50    }
51
52    if touch_id {
53        ensure_command("age-plugin-se")?;
54        run_keygen(
55            "age-plugin-se",
56            &[
57                "keygen".to_string(),
58                format!("--access-control={access_control}"),
59                "-o".to_string(),
60                output_path.to_string_lossy().into_owned(),
61            ],
62        )
63        .await?;
64    } else {
65        ensure_command("age-keygen")?;
66        run_keygen(
67            "age-keygen",
68            &["-o".to_string(), output_path.to_string_lossy().into_owned()],
69        )
70        .await?;
71    }
72
73    #[cfg(unix)]
74    set_owner_only_permissions(&output_path).await?;
75
76    let recipient = extract_recipient(&output_path).await?;
77    println!(
78        "{}",
79        colors::green(&format!(
80            "generated age identity at {}",
81            path_display::format(&output_path)
82        ))
83    );
84    println!("  recipient: {recipient}");
85    println!();
86    println!(
87        "{}",
88        colors::dim(
89            "Add this recipient to age_recipients in config.toml (or [env.encryption] in \
90             shine.workspace.toml) so others can decrypt secrets sealed for it."
91        )
92    );
93    if config.secret_backend.as_deref() != Some("age") {
94        println!(
95            "{}",
96            colors::dim(
97                "Set secret_backend = \"age\" in config.toml to make age the default for \
98                 `shine env secret encrypt`/`shine env secret seal`."
99            )
100        );
101    }
102    if config.age_identity.is_none() && output_path != default_identity_path(config) {
103        println!(
104            "{}",
105            colors::dim(&format!(
106                "Set age_identity = \"{}\" in config.toml so shine can find this identity.",
107                output_path.display()
108            ))
109        );
110    }
111    Ok(())
112}
113
114pub async fn handle_identity_list(config: &Config) -> Result<()> {
115    let identities = config.age_identities();
116    if identities.is_empty() {
117        println!(
118            "{}",
119            colors::dim("No age identity configured. Run `shine env secret identity init`.")
120        );
121        return Ok(());
122    }
123    for identity in &identities {
124        let recipient = extract_recipient(identity).await?;
125        println!("{}  {}", path_display::format(identity), recipient);
126    }
127    Ok(())
128}
129
130fn ensure_touch_id_supported(touch_id: bool, os: &str) -> Result<()> {
131    if touch_id && os != "macos" {
132        bail!(
133            "Secure Enclave identities require macOS; run `shine env secret identity init` without \
134             --touch-id to generate a plain age identity"
135        );
136    }
137    Ok(())
138}
139
140fn validate_access_control(value: &str) -> Result<()> {
141    if !VALID_ACCESS_CONTROLS.contains(&value) {
142        bail!(
143            "unknown --access-control \"{value}\"; expected one of: {}",
144            VALID_ACCESS_CONTROLS.join(", ")
145        );
146    }
147    Ok(())
148}
149
150fn default_identity_path(config: &Config) -> PathBuf {
151    config.shine_dir().join("age").join("identity.txt")
152}
153
154async fn run_keygen(program: &str, args: &[String]) -> Result<()> {
155    let status = Command::new(program)
156        .args(args)
157        .status()
158        .await
159        .with_context(|| format!("running {program}"))?;
160    if !status.success() {
161        bail!("{program} failed");
162    }
163    Ok(())
164}
165
166#[cfg(unix)]
167async fn set_owner_only_permissions(path: &Path) -> Result<()> {
168    use std::os::unix::fs::PermissionsExt;
169    let permissions = std::fs::Permissions::from_mode(0o600);
170    tokio::fs::set_permissions(path, permissions)
171        .await
172        .with_context(|| format!("setting permissions on {}", path.display()))
173}
174
175/// Extract the `age1...`/`age1se1...` recipient from an identity file's
176/// leading comment, as written by `age-keygen`/`age-plugin-se keygen`.
177async fn extract_recipient(path: &Path) -> Result<String> {
178    let contents = tokio::fs::read_to_string(path)
179        .await
180        .with_context(|| format!("reading {}", path.display()))?;
181    contents
182        .lines()
183        .filter_map(|line| line.strip_prefix('#'))
184        .map(str::trim)
185        .find_map(|line| {
186            line.split_whitespace()
187                .find(|token| token.starts_with("age1"))
188        })
189        .map(str::to_string)
190        .with_context(|| format!("no recipient found in {}", path.display()))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn touch_id_requires_macos() {
199        let err = ensure_touch_id_supported(true, "linux").unwrap_err();
200        assert!(err.to_string().contains("require macOS"), "{err:#}");
201    }
202
203    #[test]
204    fn touch_id_allowed_on_macos() {
205        assert!(ensure_touch_id_supported(true, "macos").is_ok());
206    }
207
208    #[test]
209    fn non_touch_id_allowed_on_any_os() {
210        assert!(ensure_touch_id_supported(false, "linux").is_ok());
211        assert!(ensure_touch_id_supported(false, "windows").is_ok());
212    }
213
214    #[test]
215    fn access_control_validates_known_values() {
216        for value in VALID_ACCESS_CONTROLS {
217            assert!(validate_access_control(value).is_ok());
218        }
219        let err = validate_access_control("bogus").unwrap_err();
220        assert!(
221            err.to_string().contains("unknown --access-control"),
222            "{err:#}"
223        );
224    }
225
226    #[tokio::test]
227    async fn extracts_recipient_from_identity_comment() {
228        let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
229        tokio::fs::create_dir_all(&dir).await.unwrap();
230        let path = dir.join("identity.txt");
231        tokio::fs::write(
232            &path,
233            "# created: 2026-01-01\n# public key: age1qexampleexampleexample\nAGE-SECRET-KEY-1EXAMPLE\n",
234        )
235        .await
236        .unwrap();
237
238        let recipient = extract_recipient(&path).await.unwrap();
239        assert_eq!(recipient, "age1qexampleexampleexample");
240
241        tokio::fs::remove_dir_all(&dir).await.unwrap();
242    }
243
244    #[tokio::test]
245    async fn extract_recipient_errors_without_recipient_comment() {
246        let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
247        tokio::fs::create_dir_all(&dir).await.unwrap();
248        let path = dir.join("identity.txt");
249        tokio::fs::write(&path, "AGE-SECRET-KEY-1EXAMPLE\n")
250            .await
251            .unwrap();
252
253        let err = extract_recipient(&path).await.unwrap_err();
254        assert!(err.to_string().contains("no recipient found"), "{err:#}");
255
256        tokio::fs::remove_dir_all(&dir).await.unwrap();
257    }
258
259    #[test]
260    fn default_identity_path_is_under_shine_dir() {
261        let dir = std::env::temp_dir().join(format!("shine-identity-{}", uuid::Uuid::new_v4()));
262        let config = Config::new_for_test(&dir);
263
264        assert_eq!(
265            default_identity_path(&config),
266            dir.join("age").join("identity.txt")
267        );
268    }
269}