Skip to main content

cli/secret/
mod.rs

1//! Secret storage backends for `shine env secret encrypt`/`decrypt`.
2//!
3//! Two external backends exist: GPG (the original, still the default) and age, added
4//! for multi-recipient encryption with Apple Touch ID support via
5//! `age-plugin-se` Secure Enclave identities. Ciphertext carries a backend
6//! tag (`age:<base64>`); untagged base64 continues to route to GPG so
7//! secrets encrypted before age existed keep decrypting unmodified. The versioned
8//! `hybrid:` envelope wraps one data key with both tools (ADR 0084).
9//!
10//! Encryption always needs a resolved recipient list ([`EncryptRecipients`]);
11//! decryption is purely tag-based and never consults `secret_backend`, so
12//! changing the default encrypt backend can never break existing secrets.
13
14mod 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/// Which external tool a piece of ciphertext (or an encrypt request) belongs to.
26#[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/// A resolved recipient list for encryption, tagged by backend.
50#[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
67/// Split stored ciphertext into its backend and undecorated payload.
68/// Untagged ciphertext is treated as GPG for backward compatibility with
69/// secrets encrypted before the age backend existed.
70pub 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
80/// Encrypt `plaintext` for the given recipients, returning storage-ready
81/// ciphertext (tagged for age, untagged for GPG).
82pub 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
95/// Check the age client before starting tagged phone pairing.
96pub(crate) async fn preflight_age() -> Result<()> {
97    age::preflight_age().await
98}
99
100/// Convert a legacy Secure Enclave recipient without changing its public key.
101pub(crate) fn secure_enclave_recipient_to_tag(recipient: &str) -> Result<String> {
102    age::secure_enclave_recipient_to_tag(recipient)
103}
104
105/// Validate the age executable and the complete recipient-side plugin set
106/// before a workspace seal can decrypt any existing payload.
107pub(crate) async fn preflight_age_recipients(recipients: &[String]) -> Result<()> {
108    age::preflight_recipients(recipients).await
109}
110
111/// Hybrid-derived caches retain exact workspace recipient restrictions even
112/// though the local cache only uses one encryption backend.
113pub(crate) async fn encrypt_local_cache(
114    plaintext: &[u8],
115    recipients: &EncryptRecipients,
116) -> Result<String> {
117    match recipients {
118        EncryptRecipients::Gpg(recipients) => {
119            // Apply the same full-fingerprint validation as hybrid sealing.
120            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
136/// Read a local hybrid-derived cache without honoring GPG output-file options.
137pub(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
148/// Decrypt stored ciphertext, routing purely on its tag. `age_identities` is
149/// consulted for age ciphertext and the age branch of a hybrid envelope.
150pub 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// Run the real backend adapters against controlled stand-ins with no base64 on PATH.
231#[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            // SAFETY: the environment lock is held until after this guard drops.
241            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        // SAFETY: all environment-mutation tests hold env_lock().
278        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        // SAFETY: all environment-mutation tests hold env_lock().
328        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}