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