1mod age;
14mod exec;
15mod gpg;
16
17use anyhow::{Result, bail};
18use std::path::PathBuf;
19use std::str::FromStr;
20
21const AGE_TAG_PREFIX: &str = "age:";
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
25pub enum BackendKind {
26 #[default]
27 Gpg,
28 Age,
29}
30
31impl FromStr for BackendKind {
32 type Err = anyhow::Error;
33
34 fn from_str(value: &str) -> Result<Self> {
35 match value.trim().to_ascii_lowercase().as_str() {
36 "gpg" => Ok(Self::Gpg),
37 "age" => Ok(Self::Age),
38 other => bail!("unknown secret backend \"{other}\"; expected \"gpg\" or \"age\""),
39 }
40 }
41}
42
43#[derive(Clone, Debug)]
45pub enum EncryptRecipients {
46 Gpg(Vec<String>),
47 Age(Vec<String>),
48}
49
50impl EncryptRecipients {
51 pub fn backend(&self) -> BackendKind {
52 match self {
53 Self::Gpg(_) => BackendKind::Gpg,
54 Self::Age(_) => BackendKind::Age,
55 }
56 }
57}
58
59pub fn parse_tagged_ciphertext(ciphertext: &str) -> (BackendKind, &str) {
63 match ciphertext.strip_prefix(AGE_TAG_PREFIX) {
64 Some(rest) => (BackendKind::Age, rest),
65 None => (BackendKind::Gpg, ciphertext),
66 }
67}
68
69pub async fn encrypt_secret(plaintext: &[u8], recipients: &EncryptRecipients) -> Result<String> {
72 match recipients {
73 EncryptRecipients::Gpg(recipients) => {
74 gpg::encrypt_gpg_secret_to_base64(plaintext, recipients).await
75 }
76 EncryptRecipients::Age(recipients) => {
77 let encoded = age::encrypt_age_secret_to_base64(plaintext, recipients).await?;
78 Ok(format!("{AGE_TAG_PREFIX}{encoded}"))
79 }
80 }
81}
82
83pub async fn decrypt_secret(ciphertext: &str, age_identities: &[PathBuf]) -> Result<String> {
86 let (backend, payload) = parse_tagged_ciphertext(ciphertext);
87 match backend {
88 BackendKind::Gpg => gpg::decrypt_base64_gpg_secret(payload).await,
89 BackendKind::Age => age::decrypt_base64_age_secret(payload, age_identities).await,
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn untagged_ciphertext_routes_to_gpg() {
99 let (backend, payload) = parse_tagged_ciphertext("aGVsbG8=");
100 assert_eq!(backend, BackendKind::Gpg);
101 assert_eq!(payload, "aGVsbG8=");
102 }
103
104 #[test]
105 fn age_tagged_ciphertext_routes_to_age_and_strips_tag() {
106 let (backend, payload) = parse_tagged_ciphertext("age:aGVsbG8=");
107 assert_eq!(backend, BackendKind::Age);
108 assert_eq!(payload, "aGVsbG8=");
109 }
110
111 #[test]
112 fn backend_kind_parses_case_insensitively() {
113 assert_eq!("GPG".parse::<BackendKind>().unwrap(), BackendKind::Gpg);
114 assert_eq!("Age".parse::<BackendKind>().unwrap(), BackendKind::Age);
115 }
116
117 #[test]
118 fn backend_kind_rejects_unknown_values() {
119 let err = "sops".parse::<BackendKind>().unwrap_err();
120 assert!(
121 err.to_string().contains("unknown secret backend"),
122 "{err:#}"
123 );
124 }
125
126 #[test]
127 fn backend_kind_defaults_to_gpg() {
128 assert_eq!(BackendKind::default(), BackendKind::Gpg);
129 }
130
131 #[test]
132 fn encrypt_recipients_report_their_backend() {
133 assert_eq!(
134 EncryptRecipients::Gpg(vec!["a@example.com".to_string()]).backend(),
135 BackendKind::Gpg
136 );
137 assert_eq!(
138 EncryptRecipients::Age(vec!["age1qexample".to_string()]).backend(),
139 BackendKind::Age
140 );
141 }
142}
143
144#[cfg(all(test, unix))]
146mod process_tests {
147 use super::*;
148 use std::os::unix::fs::PermissionsExt;
149
150 #[test]
151 fn both_backends_work_without_external_base64() {
152 let _guard = crate::test_support::env_lock();
153 struct RestorePath(Option<std::ffi::OsString>);
154 impl Drop for RestorePath {
155 fn drop(&mut self) {
156 unsafe {
158 match &self.0 {
159 Some(path) => std::env::set_var("PATH", path),
160 None => std::env::remove_var("PATH"),
161 }
162 }
163 }
164 }
165 let runtime = tokio::runtime::Builder::new_current_thread()
166 .enable_all()
167 .build()
168 .unwrap();
169 let dir = runtime.block_on(crate::test_support::make_temp_dir("shine-secret-process"));
170 let _restore = RestorePath(std::env::var_os("PATH"));
171 for tool in ["gpg", "age"] {
172 let path = dir.join(tool);
173 std::fs::write(
174 &path,
175 r#"#!/bin/sh
176case "$1" in
177 --encrypt|-e) /bin/cat ;;
178 --decrypt|-d) for arg in "$@"; do file="$arg"; done; /bin/cat "$file" ;;
179 *) exit 1 ;;
180esac
181"#,
182 )
183 .unwrap();
184 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
185 }
186 let identity = dir.join("identity.txt");
187 std::fs::write(&identity, "test identity").unwrap();
188 unsafe { std::env::set_var("PATH", &dir) };
190 let result: Result<()> = runtime.block_on(async {
191 assert!(crate::proc::ensure_command("base64").is_err());
192 for recipients in [
193 EncryptRecipients::Gpg(vec!["test@example.com".into()]),
194 EncryptRecipients::Age(vec!["age1test".into()]),
195 ] {
196 let plaintext = "secret\nwith trailing newline\n";
197 let encoded = encrypt_secret(plaintext.as_bytes(), &recipients).await?;
198 let expected = "c2VjcmV0CndpdGggdHJhaWxpbmcgbmV3bGluZQo=";
199 assert_eq!(
200 encoded,
201 match recipients {
202 EncryptRecipients::Gpg(_) => expected.to_string(),
203 EncryptRecipients::Age(_) => format!("age:{expected}"),
204 }
205 );
206 assert_eq!(
207 decrypt_secret(&encoded, std::slice::from_ref(&identity)).await?,
208 plaintext
209 );
210 }
211 Ok(())
212 });
213 std::fs::remove_dir_all(&dir).unwrap();
214 result.unwrap();
215 }
216}