1mod age;
15mod exec;
16mod gpg;
17pub(crate) mod hybrid;
18
19use anyhow::{Result, bail};
20use std::path::PathBuf;
21use std::str::FromStr;
22
23const AGE_TAG_PREFIX: &str = "age:";
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27pub enum BackendKind {
28 #[default]
29 Gpg,
30 Age,
31 Hybrid,
32}
33
34impl FromStr for BackendKind {
35 type Err = anyhow::Error;
36
37 fn from_str(value: &str) -> Result<Self> {
38 match value.trim().to_ascii_lowercase().as_str() {
39 "gpg" => Ok(Self::Gpg),
40 "age" => Ok(Self::Age),
41 "hybrid" => Ok(Self::Hybrid),
42 other => {
43 bail!("unknown secret backend \"{other}\"; expected \"gpg\", \"age\" or \"hybrid\"")
44 }
45 }
46 }
47}
48
49#[derive(Clone, Debug)]
51pub enum EncryptRecipients {
52 Gpg(Vec<String>),
53 Age(Vec<String>),
54 Hybrid(hybrid::Recipients),
55}
56
57impl EncryptRecipients {
58 pub fn backend(&self) -> BackendKind {
59 match self {
60 Self::Gpg(_) => BackendKind::Gpg,
61 Self::Age(_) => BackendKind::Age,
62 Self::Hybrid(_) => BackendKind::Hybrid,
63 }
64 }
65}
66
67pub fn parse_tagged_ciphertext(ciphertext: &str) -> (BackendKind, &str) {
71 if let Some(rest) = ciphertext.strip_prefix(hybrid::PREFIX) {
72 return (BackendKind::Hybrid, rest);
73 }
74 match ciphertext.strip_prefix(AGE_TAG_PREFIX) {
75 Some(rest) => (BackendKind::Age, rest),
76 None => (BackendKind::Gpg, ciphertext),
77 }
78}
79
80pub async fn encrypt_secret(plaintext: &[u8], recipients: &EncryptRecipients) -> Result<String> {
83 match recipients {
84 EncryptRecipients::Hybrid(recipients) => hybrid::encrypt(plaintext, recipients).await,
85 EncryptRecipients::Gpg(recipients) => {
86 gpg::encrypt_gpg_secret_to_base64(plaintext, recipients).await
87 }
88 EncryptRecipients::Age(recipients) => {
89 let encoded = age::encrypt_age_secret_to_base64(plaintext, recipients).await?;
90 Ok(format!("{AGE_TAG_PREFIX}{encoded}"))
91 }
92 }
93}
94
95pub(crate) async fn preflight_age() -> Result<()> {
97 age::preflight_age().await
98}
99
100pub(crate) fn secure_enclave_recipient_to_tag(recipient: &str) -> Result<String> {
102 age::secure_enclave_recipient_to_tag(recipient)
103}
104
105pub(crate) async fn preflight_age_recipients(recipients: &[String]) -> Result<()> {
108 age::preflight_recipients(recipients).await
109}
110
111pub(crate) async fn encrypt_local_cache(
114 plaintext: &[u8],
115 recipients: &EncryptRecipients,
116) -> Result<String> {
117 match recipients {
118 EncryptRecipients::Gpg(recipients) => {
119 if recipients.is_empty()
121 || recipients.iter().any(|value| {
122 value.len() != 40 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
123 })
124 {
125 bail!("hybrid cache GPG recipients must be full 40-hex primary fingerprints");
126 }
127 let resolved = gpg::resolve_hybrid_recipients(recipients).await?;
128 let encrypted = gpg::encrypt_hybrid_key(plaintext, &resolved).await?;
129 Ok(exec::encode_base64_single_line(&encrypted))
130 }
131 EncryptRecipients::Age(_) => encrypt_secret(plaintext, recipients).await,
132 EncryptRecipients::Hybrid(_) => bail!("local cache requires one backend"),
133 }
134}
135
136pub(crate) async fn decrypt_local_cache(
138 ciphertext: &str,
139 config: &crate::config::Config,
140) -> Result<String> {
141 match parse_tagged_ciphertext(ciphertext) {
142 (BackendKind::Gpg, payload) => gpg::decrypt_cache(payload).await,
143 (BackendKind::Age, _) => decrypt_with_config(ciphertext, config).await,
144 (BackendKind::Hybrid, _) => bail!("local cache requires one backend"),
145 }
146}
147
148pub async fn decrypt_secret(ciphertext: &str, age_identities: &[PathBuf]) -> Result<String> {
151 decrypt_with_preference(ciphertext, age_identities, None).await
152}
153
154pub async fn decrypt_with_config(
155 ciphertext: &str,
156 config: &crate::config::Config,
157) -> Result<String> {
158 decrypt_with_preference(
159 ciphertext,
160 &config.resolved_age_identities(),
161 config.hybrid_decrypt_backend.as_deref(),
162 )
163 .await
164}
165
166async fn decrypt_with_preference(
167 ciphertext: &str,
168 age_identities: &[PathBuf],
169 preference: Option<&str>,
170) -> Result<String> {
171 let (backend, payload) = parse_tagged_ciphertext(ciphertext);
172 match backend {
173 BackendKind::Hybrid => hybrid::decrypt(payload, age_identities, preference).await,
174 BackendKind::Gpg => gpg::decrypt_base64_gpg_secret(payload).await,
175 BackendKind::Age => age::decrypt_base64_age_secret(payload, age_identities).await,
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn untagged_ciphertext_routes_to_gpg() {
185 let (backend, payload) = parse_tagged_ciphertext("aGVsbG8=");
186 assert_eq!(backend, BackendKind::Gpg);
187 assert_eq!(payload, "aGVsbG8=");
188 }
189
190 #[test]
191 fn age_tagged_ciphertext_routes_to_age_and_strips_tag() {
192 let (backend, payload) = parse_tagged_ciphertext("age:aGVsbG8=");
193 assert_eq!(backend, BackendKind::Age);
194 assert_eq!(payload, "aGVsbG8=");
195 }
196
197 #[test]
198 fn backend_kind_parses_case_insensitively() {
199 assert_eq!("GPG".parse::<BackendKind>().unwrap(), BackendKind::Gpg);
200 assert_eq!("Age".parse::<BackendKind>().unwrap(), BackendKind::Age);
201 }
202
203 #[test]
204 fn backend_kind_rejects_unknown_values() {
205 let err = "sops".parse::<BackendKind>().unwrap_err();
206 assert!(
207 err.to_string().contains("unknown secret backend"),
208 "{err:#}"
209 );
210 }
211
212 #[test]
213 fn backend_kind_defaults_to_gpg() {
214 assert_eq!(BackendKind::default(), BackendKind::Gpg);
215 }
216
217 #[test]
218 fn encrypt_recipients_report_their_backend() {
219 assert_eq!(
220 EncryptRecipients::Gpg(vec!["a@example.com".to_string()]).backend(),
221 BackendKind::Gpg
222 );
223 assert_eq!(
224 EncryptRecipients::Age(vec!["age1qexample".to_string()]).backend(),
225 BackendKind::Age
226 );
227 }
228}
229
230#[cfg(all(test, unix))]
232mod process_tests {
233 use super::*;
234 use std::os::unix::fs::PermissionsExt;
235
236 struct RestorePath(Option<std::ffi::OsString>);
237
238 impl Drop for RestorePath {
239 fn drop(&mut self) {
240 unsafe {
242 match &self.0 {
243 Some(path) => std::env::set_var("PATH", path),
244 None => std::env::remove_var("PATH"),
245 }
246 }
247 }
248 }
249
250 #[test]
251 fn both_backends_work_without_external_base64() {
252 let _guard = crate::test_support::env_lock();
253 let runtime = tokio::runtime::Builder::new_current_thread()
254 .enable_all()
255 .build()
256 .unwrap();
257 let dir = runtime.block_on(crate::test_support::make_temp_dir("shine-secret-process"));
258 let _restore = RestorePath(std::env::var_os("PATH"));
259 for tool in ["gpg", "age"] {
260 let path = dir.join(tool);
261 std::fs::write(
262 &path,
263 r#"#!/bin/sh
264case "$1" in
265 --version) if test "${0##*/}" = age; then echo v1.3.0; else echo 'gpg 2.4.0'; fi ;;
266 --encrypt|-e) /bin/cat ;;
267 --decrypt|-d) for arg in "$@"; do file="$arg"; done; /bin/cat "$file" ;;
268 *) exit 1 ;;
269esac
270"#,
271 )
272 .unwrap();
273 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
274 }
275 let identity = dir.join("identity.txt");
276 std::fs::write(&identity, "test identity").unwrap();
277 unsafe { std::env::set_var("PATH", &dir) };
279 let result: Result<()> = runtime.block_on(async {
280 assert!(crate::proc::ensure_command("base64").is_err());
281 for recipients in [
282 EncryptRecipients::Gpg(vec!["test@example.com".into()]),
283 EncryptRecipients::Age(vec!["age1tag1test".into()]),
284 ] {
285 let plaintext = "secret\nwith trailing newline\n";
286 let encoded = encrypt_secret(plaintext.as_bytes(), &recipients).await?;
287 let expected = "c2VjcmV0CndpdGggdHJhaWxpbmcgbmV3bGluZQo=";
288 assert_eq!(
289 encoded,
290 match recipients {
291 EncryptRecipients::Gpg(_) => expected.to_string(),
292 EncryptRecipients::Age(_) => format!("age:{expected}"),
293 EncryptRecipients::Hybrid(_) => unreachable!(),
294 }
295 );
296 assert_eq!(
297 decrypt_secret(&encoded, std::slice::from_ref(&identity)).await?,
298 plaintext
299 );
300 }
301
302 let legacy = EncryptRecipients::Age(vec!["age1se1legacy".into()]);
303 let missing = encrypt_secret(b"secret", &legacy).await.unwrap_err();
304 assert!(
305 missing
306 .to_string()
307 .contains("shine state migrate --dry-run")
308 );
309 std::fs::write(dir.join("age-plugin-se"), "").unwrap();
310 assert!(encrypt_secret(b"secret", &legacy).await.is_ok());
311 Ok(())
312 });
313 std::fs::remove_dir_all(&dir).unwrap();
314 result.unwrap();
315 }
316
317 #[test]
318 fn age_version_is_enforced_at_the_process_boundary() {
319 let _guard = crate::test_support::env_lock();
320 let runtime = tokio::runtime::Builder::new_current_thread()
321 .enable_all()
322 .build()
323 .unwrap();
324 let dir = runtime.block_on(crate::test_support::make_temp_dir("shine-age-version"));
325 let age = dir.join("age");
326 let _restore = RestorePath(std::env::var_os("PATH"));
327 unsafe { std::env::set_var("PATH", &dir) };
329
330 for (output, accepted) in [("v1.2.0", false), ("unexpected", false), ("v1.3.0", true)] {
331 std::fs::write(&age, format!("#!/bin/sh\necho '{output}'\n")).unwrap();
332 std::fs::set_permissions(&age, std::fs::Permissions::from_mode(0o700)).unwrap();
333 assert_eq!(runtime.block_on(age::preflight_age()).is_ok(), accepted);
334 }
335
336 std::fs::remove_dir_all(&dir).unwrap();
337 }
338}