Skip to main content

cli/secret/
mod.rs

1//! Secret storage backends for `shine env secret encrypt`/`decrypt`.
2//!
3//! Two 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.
8//!
9//! Encryption always needs a resolved recipient list ([`EncryptRecipients`]);
10//! decryption is purely tag-based and never consults `secret_backend`, so
11//! changing the default encrypt backend can never break existing secrets.
12
13mod 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/// Which external tool a piece of ciphertext (or an encrypt request) belongs to.
24#[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/// A resolved recipient list for encryption, tagged by backend.
44#[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
59/// Split stored ciphertext into its backend and undecorated payload.
60/// Untagged ciphertext is treated as GPG for backward compatibility with
61/// secrets encrypted before the age backend existed.
62pub 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
69/// Encrypt `plaintext` for the given recipients, returning storage-ready
70/// ciphertext (tagged for age, untagged for GPG).
71pub 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
83/// Decrypt stored ciphertext, routing purely on its tag. `age_identities` is
84/// only consulted when the ciphertext is tagged `age:`.
85pub 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// Run the real backend adapters against controlled stand-ins with no base64 on PATH.
145#[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                // SAFETY: the environment lock is held until after this guard drops.
157                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        // SAFETY: all environment-mutation tests hold env_lock().
189        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}