From 0cfa0d580a6d70ee614db191dc70e44adc60593c Mon Sep 17 00:00:00 2001 From: Marukome0743 Date: Sat, 15 Aug 2026 20:04:38 +0900 Subject: [PATCH] fix(sops): support multiple age keys --- docs/environments/secrets/sops.md | 4 + e2e/secrets/test_sops_multiple_age_keys | 43 ++++++++ src/config/env_directive/file.rs | 75 ++++++++++++++ src/sops.rs | 132 ++++++++++++++++++------ 4 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 e2e/secrets/test_sops_multiple_age_keys diff --git a/docs/environments/secrets/sops.md b/docs/environments/secrets/sops.md index e0586751586..3179c26ef47 100644 --- a/docs/environments/secrets/sops.md +++ b/docs/environments/secrets/sops.md @@ -53,6 +53,10 @@ sops encrypt -i --age "" .env.json The `-i` overwrites the file. The encrypted file is safe to commit. Set `SOPS_AGE_KEY_FILE=~/.config/mise/age.txt` or `MISE_SOPS_AGE_KEY_FILE=~/.config/mise/age.txt` to decrypt/edit with sops. :::: +Age key files use the standard SOPS/age format: put one identity on each line. +Blank lines and lines beginning with `#` are ignored, and all identities are +tried when decrypting. + 4. Reference it in config: ```toml diff --git a/e2e/secrets/test_sops_multiple_age_keys b/e2e/secrets/test_sops_multiple_age_keys new file mode 100644 index 00000000000..ec9f32594dd --- /dev/null +++ b/e2e/secrets/test_sops_multiple_age_keys @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -x + +unset MISE_SOPS_AGE_KEY MISE_SOPS_AGE_KEY_FILE SOPS_AGE_KEY SOPS_AGE_KEY_FILE + +cat >age.txt <<'EOF' +# unrelated identity +AGE-SECRET-KEY-1W92VNVAX0YKJX4WQ6SV7T7X2PZYUC0STF5TKJLQ9ZUWM62HLMN3QYQZJ6F + +# matching identity +AGE-SECRET-KEY-1EQUCGFZH8UZKSZ0Z5N5T234YRNDT4U9H7QNYXWRRNJYDDVXE6FWSCPGNJ7 +EOF + +cat >.env.json <<'EOF' +{ + "SECRET": "ENC[AES256_GCM,data:II2ttLXqvIpBLJrIIK/6TQ==,iv:vnFEc+xEFpAvC2y/HJ5r3iQKy06mMkHcGReEC8KABYw=,tag:SdU+3uZSrdVyzULwf9jrGQ==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2dkhKTGhTK3RqUER2TkM2\nUTVLYThRR1Job1ZPaGZFU0VCYStIV21jT1VFClhKaXZ3K1hGclR1aHhNVWlwelZC\nNkhXL2s5a3JZblM0VEhjSks5QVpDQTQKLS0tIDlFbmdHRmN3ZFNSMzF3d0Z1L2Vs\nQnY0MmQ4YWxGL0loZVRieVJqRUpvRWMK/YRR61rqdABu9dMQd+LvbXeQ2SyEkuZh\nkJpjTpH6y9mJu2o+hKVOBwLSZJxfH9DyuDDtNbiU4lhALL4dM6l+UQ==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age1se5ghfycr4n8kcwc3qwf234ymvmr2lex2a99wh8gpfx97glwt9hqch4569" + } + ], + "lastmodified": "2026-08-15T10:20:11Z", + "mac": "ENC[AES256_GCM,data:IdyCDYpjoE9JYLPen61YJ1GP8XelCWQyR5U/wH7Y2/wHKFgtairnUG3YtLxzGcx0a5MLyqUk4ny66JD+Nc1JKcfLR+HWLEVk4MFLmkTxNQGXTZ9LTGk/rNYaPy+K5ekRih4Yo4S5fYf1Kvz8oKy+FLtWF87ljy8nqZKvvUVkBo8=,iv:f/0OUbTHBmoWF/1yQ19w6YjY3MkWUi4YIcZZPGTxTaY=,tag:RNWhHdiD1uV+4oV16kSoOw==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.13.3" + } +} +EOF + +cat >mise.toml <<'EOF' +[env] +_.file = ".env.json" + +[settings] +experimental = true +sops.rops = true +sops.age_key_file = "./age.txt" +EOF + +assert_contains "mise env" "export SECRET=multi-key-secret" +assert_contains "mise exec -- env" "SECRET=multi-key-secret" diff --git a/src/config/env_directive/file.rs b/src/config/env_directive/file.rs index 7f6b5d93c60..16cfa74c371 100644 --- a/src/config/env_directive/file.rs +++ b/src/config/env_directive/file.rs @@ -300,6 +300,8 @@ mod tests { const AGE_PUBLIC_KEY: &str = "age1se5ghfycr4n8kcwc3qwf234ymvmr2lex2a99wh8gpfx97glwt9hqch4569"; const AGE_PRIVATE_KEY: &str = "AGE-SECRET-KEY-1EQUCGFZH8UZKSZ0Z5N5T234YRNDT4U9H7QNYXWRRNJYDDVXE6FWSCPGNJ7"; + const UNRELATED_AGE_PRIVATE_KEY: &str = + "AGE-SECRET-KEY-1W92VNVAX0YKJX4WQ6SV7T7X2PZYUC0STF5TKJLQ9ZUWM62HLMN3QYQZJ6F"; static ENV_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); fn encrypted_toml() -> String { @@ -374,6 +376,79 @@ mod tests { Settings::reset(None); } + #[tokio::test] + async fn decrypts_sops_toml_file_with_multiple_age_keys() { + let _lock = ENV_MUTEX.lock().await; + let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok(); + let prev_age_key_file = crate::env::var("MISE_SOPS_AGE_KEY_FILE").ok(); + let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok(); + crate::env::remove_var("MISE_SOPS_AGE_KEY"); + crate::env::remove_var("MISE_SOPS_AGE_KEY_FILE"); + crate::env::remove_var("MISE_SOPS_ROPS"); + Settings::reset(None); + let config = Config::reset().await.unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join(".env.toml"); + let key_file = tmp.path().join("age.txt"); + file::write(&p, encrypted_toml()).unwrap(); + file::write( + &key_file, + format!( + "# unrelated identity\r\n{UNRELATED_AGE_PRIVATE_KEY}\r\n\r\n# matching identity\r\n{AGE_PRIVATE_KEY}\r\n" + ), + ) + .unwrap(); + + let mut exec_env = TeraEnvMap::new(); + exec_env.insert( + "MISE_SOPS_AGE_KEY_FILE".into(), + key_file.to_string_lossy().to_string(), + ); + let env = EnvResults::toml(&config, &exec_env, &p, Ok).await.unwrap(); + assert_eq!(env.get("SECRET").unwrap(), "mysecret"); + + restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key); + restore_env_var("MISE_SOPS_AGE_KEY_FILE", prev_age_key_file); + restore_env_var("MISE_SOPS_ROPS", prev_rops); + Settings::reset(None); + } + + #[tokio::test] + async fn rejects_invalid_non_comment_age_key_lines() { + let _lock = ENV_MUTEX.lock().await; + let prev_age_key = crate::env::var("MISE_SOPS_AGE_KEY").ok(); + let prev_age_key_file = crate::env::var("MISE_SOPS_AGE_KEY_FILE").ok(); + let prev_rops = crate::env::var("MISE_SOPS_ROPS").ok(); + crate::env::remove_var("MISE_SOPS_AGE_KEY"); + crate::env::remove_var("MISE_SOPS_AGE_KEY_FILE"); + crate::env::remove_var("MISE_SOPS_ROPS"); + Settings::reset(None); + let config = Config::reset().await.unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join(".env.toml"); + let key_file = tmp.path().join("age.txt"); + file::write(&p, encrypted_toml()).unwrap(); + file::write(&key_file, format!("not-an-age-key\n{AGE_PRIVATE_KEY}\n")).unwrap(); + + let mut exec_env = TeraEnvMap::new(); + exec_env.insert( + "MISE_SOPS_AGE_KEY_FILE".into(), + key_file.to_string_lossy().to_string(), + ); + let err = EnvResults::toml(&config, &exec_env, &p, Ok) + .await + .unwrap_err(); + assert!( + err.to_string().contains("failed to decrypt sops file"), + "{err}" + ); + + restore_env_var("MISE_SOPS_AGE_KEY", prev_age_key); + restore_env_var("MISE_SOPS_AGE_KEY_FILE", prev_age_key_file); + restore_env_var("MISE_SOPS_ROPS", prev_rops); + Settings::reset(None); + } + #[tokio::test] async fn ambient_sops_age_key_file_precedes_exec_env_sops_age_key() { let _lock = ENV_MUTEX.lock().await; diff --git a/src/sops.rs b/src/sops.rs index 0053561e895..01ea76a85d5 100644 --- a/src/sops.rs +++ b/src/sops.rs @@ -1,3 +1,4 @@ +use std::path::{Path, PathBuf}; use std::sync::Arc; use crate::backend::configured_toolset_or_path_which; @@ -13,6 +14,32 @@ use rops::file::RopsFile; use rops::file::state::EncryptedFile; use tokio::sync::Mutex; +#[derive(Debug, PartialEq)] +enum ResolvedAgeKey { + Direct(String), + File { + identities: Vec, + path: PathBuf, + }, +} + +impl ResolvedAgeKey { + fn env_value(&self, use_rops: bool) -> String { + match self { + Self::Direct(key) => key.clone(), + Self::File { identities, .. } if use_rops => identities.join(","), + Self::File { identities, .. } => identities.join("\n"), + } + } + + fn file_path(&self) -> Option<&Path> { + match self { + Self::Direct(_) => None, + Self::File { path, .. } => Some(path.as_path()), + } + } +} + pub async fn decrypt( config: &Arc, exec_env: &EnvMap, @@ -33,7 +60,7 @@ where )); } - let (age, age_key_file) = resolve_age_key(exec_env, &mut parse_template); + let age = resolve_age_key(exec_env, &mut parse_template); if use_rops && age.is_none() && !Settings::get().sops.strict { debug!("age key not found, skipping decryption in non-strict mode"); @@ -46,7 +73,7 @@ where let prev_age_key_file = env::var("SOPS_AGE_KEY_FILE").ok(); // Set SOPS_AGE_KEY_FILE with expanded path if we found one, so sops CLI can use it - if let Some(expanded_path) = &age_key_file { + if let Some(expanded_path) = age.as_ref().and_then(ResolvedAgeKey::file_path) { env::set_var( "SOPS_AGE_KEY_FILE", expanded_path.to_string_lossy().to_string(), @@ -54,7 +81,7 @@ where } if let Some(age) = &age { - env::set_var(age_env_key, age.trim()); + env::set_var(age_env_key, age.env_value(use_rops).trim()); } let output = if use_rops { match input @@ -162,10 +189,7 @@ where Ok(output.unwrap_or_default()) } -fn resolve_age_key( - env: &EnvMap, - parse_template: &mut PT, -) -> (Option, Option) +fn resolve_age_key(env: &EnvMap, parse_template: &mut PT) -> Option where PT: FnMut(String) -> result::Result, { @@ -173,74 +197,72 @@ where if let Some(age_key) = &Settings::get().sops.age_key && !age_key.is_empty() { - return (Some(age_key.clone()), None); + return Some(ResolvedAgeKey::Direct(age_key.clone())); } // 2. Check mise-specific MISE_SOPS_AGE_KEY_FILE setting if let Some(key_file) = &Settings::get().sops.age_key_file - && let Some((key, path)) = read_age_key_file( + && let Some(key) = read_age_key_file( key_file.to_string_lossy().to_string(), parse_template, "MISE_SOPS_AGE_KEY_FILE", ) { - return (Some(key), Some(path)); + return Some(key); } // 3. Check ordered env directives that have already been resolved if let Some(age_key) = env.get("MISE_SOPS_AGE_KEY").filter(|key| !key.is_empty()) { - return (Some(age_key.clone()), None); + return Some(ResolvedAgeKey::Direct(age_key.clone())); } if let Some(key_file) = env.get("MISE_SOPS_AGE_KEY_FILE") - && let Some((key, path)) = + && let Some(key) = read_age_key_file(key_file.clone(), parse_template, "MISE_SOPS_AGE_KEY_FILE") { - return (Some(key), Some(path)); + return Some(key); } if let Some(key_file) = env.get("SOPS_AGE_KEY_FILE") - && let Some((key, path)) = - read_age_key_file(key_file.clone(), parse_template, "SOPS_AGE_KEY_FILE") + && let Some(key) = read_age_key_file(key_file.clone(), parse_template, "SOPS_AGE_KEY_FILE") { - return (Some(key), Some(path)); + return Some(key); } // 4. Check standard SOPS environment variables if let Ok(key_file_path) = env::var("SOPS_AGE_KEY_FILE") - && let Some((key, path)) = - read_age_key_file(key_file_path, parse_template, "SOPS_AGE_KEY_FILE") + && let Some(key) = read_age_key_file(key_file_path, parse_template, "SOPS_AGE_KEY_FILE") { - return (Some(key), Some(path)); + return Some(key); } if let Some(age_key) = env.get("SOPS_AGE_KEY").filter(|key| !key.trim().is_empty()) { - return (Some(age_key.trim().to_string()), None); + return Some(ResolvedAgeKey::Direct(age_key.trim().to_string())); } if let Ok(key) = env::var("SOPS_AGE_KEY") && !key.trim().is_empty() { - return (Some(key.trim().to_string()), None); + return Some(ResolvedAgeKey::Direct(key.trim().to_string())); } // 5. Fall back to default path ~/.config/mise/age.txt - if let Some((key, path)) = read_age_key_file( + if let Some(key) = read_age_key_file( dirs::CONFIG.join("age.txt").to_string_lossy().to_string(), parse_template, "default sops age key file", ) { - return (Some(key), Some(path)); + return Some(key); } - (None, None) + None } fn read_age_key_file( key_file_path: String, parse_template: &mut PT, source: &str, -) -> Option<(String, std::path::PathBuf)> +) -> Option where PT: FnMut(String) -> result::Result, { @@ -254,14 +276,62 @@ where if p.exists() && let Ok(raw) = file::read_to_string(&p) { - let key = raw - .trim() + let identities = raw .lines() - .filter(|l| !l.starts_with('#')) - .collect::(); - if !key.trim().is_empty() { - return Some((key, p)); + .filter(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with('#') + }) + .map(str::to_string) + .collect::>(); + if !identities.is_empty() { + return Some(ResolvedAgeKey::File { + identities, + path: p, + }); } } None } + +#[cfg(test)] +mod tests { + use super::*; + + fn read_key_file(contents: &str) -> Option { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("keys.txt"); + file::write(&path, contents).unwrap(); + read_age_key_file(path.to_string_lossy().to_string(), &mut Ok, "test key file") + } + + #[test] + fn reads_multiple_age_keys_in_order() { + let key = + read_key_file("# first key is unrelated\r\nKEY-1\r\n\r\n# matching key\r\nKEY-2\r\n") + .unwrap(); + let ResolvedAgeKey::File { identities, .. } = &key else { + panic!("expected key file"); + }; + assert_eq!(identities, &["KEY-1", "KEY-2"]); + assert_eq!(key.env_value(true), "KEY-1,KEY-2"); + assert_eq!(key.env_value(false), "KEY-1\nKEY-2"); + } + + #[test] + fn preserves_invalid_non_comment_lines() { + let key = read_key_file("not-an-age-key\n").unwrap(); + let ResolvedAgeKey::File { identities, .. } = key else { + panic!("expected key file"); + }; + assert_eq!(identities, &["not-an-age-key"]); + } + + #[test] + fn ignores_empty_and_comment_only_key_files() { + assert_eq!( + read_key_file("\n \t\r\n# no identities\r\n # indented comment\r\n"), + None + ); + } +}