mod age;
mod exec;
mod gpg;
pub(crate) mod hybrid;
use anyhow::{Result, bail};
use std::path::PathBuf;
use std::str::FromStr;
const AGE_TAG_PREFIX: &str = "age:";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum BackendKind {
#[default]
Gpg,
Age,
Hybrid,
}
impl FromStr for BackendKind {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"gpg" => Ok(Self::Gpg),
"age" => Ok(Self::Age),
"hybrid" => Ok(Self::Hybrid),
other => {
bail!("unknown secret backend \"{other}\"; expected \"gpg\", \"age\" or \"hybrid\"")
}
}
}
}
#[derive(Clone, Debug)]
pub enum EncryptRecipients {
Gpg(Vec<String>),
Age(Vec<String>),
Hybrid(hybrid::Recipients),
}
impl EncryptRecipients {
pub fn backend(&self) -> BackendKind {
match self {
Self::Gpg(_) => BackendKind::Gpg,
Self::Age(_) => BackendKind::Age,
Self::Hybrid(_) => BackendKind::Hybrid,
}
}
}
pub fn parse_tagged_ciphertext(ciphertext: &str) -> (BackendKind, &str) {
if let Some(rest) = ciphertext.strip_prefix(hybrid::PREFIX) {
return (BackendKind::Hybrid, rest);
}
match ciphertext.strip_prefix(AGE_TAG_PREFIX) {
Some(rest) => (BackendKind::Age, rest),
None => (BackendKind::Gpg, ciphertext),
}
}
pub async fn encrypt_secret(plaintext: &[u8], recipients: &EncryptRecipients) -> Result<String> {
match recipients {
EncryptRecipients::Hybrid(recipients) => hybrid::encrypt(plaintext, recipients).await,
EncryptRecipients::Gpg(recipients) => {
gpg::encrypt_gpg_secret_to_base64(plaintext, recipients).await
}
EncryptRecipients::Age(recipients) => {
let encoded = age::encrypt_age_secret_to_base64(plaintext, recipients).await?;
Ok(format!("{AGE_TAG_PREFIX}{encoded}"))
}
}
}
pub(crate) async fn preflight_age() -> Result<()> {
age::preflight_age().await
}
pub(crate) fn secure_enclave_recipient_to_tag(recipient: &str) -> Result<String> {
age::secure_enclave_recipient_to_tag(recipient)
}
pub(crate) async fn preflight_age_recipients(recipients: &[String]) -> Result<()> {
age::preflight_recipients(recipients).await
}
pub(crate) async fn encrypt_local_cache(
plaintext: &[u8],
recipients: &EncryptRecipients,
) -> Result<String> {
match recipients {
EncryptRecipients::Gpg(recipients) => {
if recipients.is_empty()
|| recipients.iter().any(|value| {
value.len() != 40 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())
})
{
bail!("hybrid cache GPG recipients must be full 40-hex primary fingerprints");
}
let resolved = gpg::resolve_hybrid_recipients(recipients).await?;
let encrypted = gpg::encrypt_hybrid_key(plaintext, &resolved).await?;
Ok(exec::encode_base64_single_line(&encrypted))
}
EncryptRecipients::Age(_) => encrypt_secret(plaintext, recipients).await,
EncryptRecipients::Hybrid(_) => bail!("local cache requires one backend"),
}
}
pub(crate) async fn decrypt_local_cache(
ciphertext: &str,
config: &crate::config::Config,
) -> Result<String> {
match parse_tagged_ciphertext(ciphertext) {
(BackendKind::Gpg, payload) => gpg::decrypt_cache(payload).await,
(BackendKind::Age, _) => decrypt_with_config(ciphertext, config).await,
(BackendKind::Hybrid, _) => bail!("local cache requires one backend"),
}
}
pub async fn decrypt_secret(ciphertext: &str, age_identities: &[PathBuf]) -> Result<String> {
decrypt_with_preference(ciphertext, age_identities, None).await
}
pub async fn decrypt_with_config(
ciphertext: &str,
config: &crate::config::Config,
) -> Result<String> {
decrypt_with_preference(
ciphertext,
&config.resolved_age_identities(),
config.hybrid_decrypt_backend.as_deref(),
)
.await
}
async fn decrypt_with_preference(
ciphertext: &str,
age_identities: &[PathBuf],
preference: Option<&str>,
) -> Result<String> {
let (backend, payload) = parse_tagged_ciphertext(ciphertext);
match backend {
BackendKind::Hybrid => hybrid::decrypt(payload, age_identities, preference).await,
BackendKind::Gpg => gpg::decrypt_base64_gpg_secret(payload).await,
BackendKind::Age => age::decrypt_base64_age_secret(payload, age_identities).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untagged_ciphertext_routes_to_gpg() {
let (backend, payload) = parse_tagged_ciphertext("aGVsbG8=");
assert_eq!(backend, BackendKind::Gpg);
assert_eq!(payload, "aGVsbG8=");
}
#[test]
fn age_tagged_ciphertext_routes_to_age_and_strips_tag() {
let (backend, payload) = parse_tagged_ciphertext("age:aGVsbG8=");
assert_eq!(backend, BackendKind::Age);
assert_eq!(payload, "aGVsbG8=");
}
#[test]
fn backend_kind_parses_case_insensitively() {
assert_eq!("GPG".parse::<BackendKind>().unwrap(), BackendKind::Gpg);
assert_eq!("Age".parse::<BackendKind>().unwrap(), BackendKind::Age);
}
#[test]
fn backend_kind_rejects_unknown_values() {
let err = "sops".parse::<BackendKind>().unwrap_err();
assert!(
err.to_string().contains("unknown secret backend"),
"{err:#}"
);
}
#[test]
fn backend_kind_defaults_to_gpg() {
assert_eq!(BackendKind::default(), BackendKind::Gpg);
}
#[test]
fn encrypt_recipients_report_their_backend() {
assert_eq!(
EncryptRecipients::Gpg(vec!["a@example.com".to_string()]).backend(),
BackendKind::Gpg
);
assert_eq!(
EncryptRecipients::Age(vec!["age1qexample".to_string()]).backend(),
BackendKind::Age
);
}
}
#[cfg(all(test, unix))]
mod process_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
struct RestorePath(Option<std::ffi::OsString>);
impl Drop for RestorePath {
fn drop(&mut self) {
unsafe {
match &self.0 {
Some(path) => std::env::set_var("PATH", path),
None => std::env::remove_var("PATH"),
}
}
}
}
#[test]
fn both_backends_work_without_external_base64() {
let _guard = crate::test_support::env_lock();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let dir = runtime.block_on(crate::test_support::make_temp_dir("shine-secret-process"));
let _restore = RestorePath(std::env::var_os("PATH"));
for tool in ["gpg", "age"] {
let path = dir.join(tool);
std::fs::write(
&path,
r#"#!/bin/sh
case "$1" in
--version) if test "${0##*/}" = age; then echo v1.3.0; else echo 'gpg 2.4.0'; fi ;;
--encrypt|-e) /bin/cat ;;
--decrypt|-d) for arg in "$@"; do file="$arg"; done; /bin/cat "$file" ;;
*) exit 1 ;;
esac
"#,
)
.unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let identity = dir.join("identity.txt");
std::fs::write(&identity, "test identity").unwrap();
unsafe { std::env::set_var("PATH", &dir) };
let result: Result<()> = runtime.block_on(async {
assert!(crate::proc::ensure_command("base64").is_err());
for recipients in [
EncryptRecipients::Gpg(vec!["test@example.com".into()]),
EncryptRecipients::Age(vec!["age1tag1test".into()]),
] {
let plaintext = "secret\nwith trailing newline\n";
let encoded = encrypt_secret(plaintext.as_bytes(), &recipients).await?;
let expected = "c2VjcmV0CndpdGggdHJhaWxpbmcgbmV3bGluZQo=";
assert_eq!(
encoded,
match recipients {
EncryptRecipients::Gpg(_) => expected.to_string(),
EncryptRecipients::Age(_) => format!("age:{expected}"),
EncryptRecipients::Hybrid(_) => unreachable!(),
}
);
assert_eq!(
decrypt_secret(&encoded, std::slice::from_ref(&identity)).await?,
plaintext
);
}
let legacy = EncryptRecipients::Age(vec!["age1se1legacy".into()]);
let missing = encrypt_secret(b"secret", &legacy).await.unwrap_err();
assert!(
missing
.to_string()
.contains("shine state migrate --dry-run")
);
std::fs::write(dir.join("age-plugin-se"), "").unwrap();
assert!(encrypt_secret(b"secret", &legacy).await.is_ok());
Ok(())
});
std::fs::remove_dir_all(&dir).unwrap();
result.unwrap();
}
#[test]
fn age_version_is_enforced_at_the_process_boundary() {
let _guard = crate::test_support::env_lock();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let dir = runtime.block_on(crate::test_support::make_temp_dir("shine-age-version"));
let age = dir.join("age");
let _restore = RestorePath(std::env::var_os("PATH"));
unsafe { std::env::set_var("PATH", &dir) };
for (output, accepted) in [("v1.2.0", false), ("unexpected", false), ("v1.3.0", true)] {
std::fs::write(&age, format!("#!/bin/sh\necho '{output}'\n")).unwrap();
std::fs::set_permissions(&age, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(runtime.block_on(age::preflight_age()).is_ok(), accepted);
}
std::fs::remove_dir_all(&dir).unwrap();
}
}