Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/environments/secrets/sops.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ sops encrypt -i --age "<public key>" .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
Expand Down
43 changes: 43 additions & 0 deletions e2e/secrets/test_sops_multiple_age_keys
Original file line number Diff line number Diff line change
@@ -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"
75 changes: 75 additions & 0 deletions src/config/env_directive/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
132 changes: 101 additions & 31 deletions src/sops.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::backend::configured_toolset_or_path_which;
Expand All @@ -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<String>,
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<PT, F>(
config: &Arc<Config>,
exec_env: &EnvMap,
Expand All @@ -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");
Expand All @@ -46,15 +73,15 @@ 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(),
);
}

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
Expand Down Expand Up @@ -162,85 +189,80 @@ where
Ok(output.unwrap_or_default())
}

fn resolve_age_key<PT>(
env: &EnvMap,
parse_template: &mut PT,
) -> (Option<String>, Option<std::path::PathBuf>)
fn resolve_age_key<PT>(env: &EnvMap, parse_template: &mut PT) -> Option<ResolvedAgeKey>
where
PT: FnMut(String) -> result::Result<String>,
{
// 1. Check mise-specific MISE_SOPS_AGE_KEY setting first (highest priority)
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<PT>(
key_file_path: String,
parse_template: &mut PT,
source: &str,
) -> Option<(String, std::path::PathBuf)>
) -> Option<ResolvedAgeKey>
where
PT: FnMut(String) -> result::Result<String>,
{
Expand All @@ -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::<String>();
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::<Vec<_>>();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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<ResolvedAgeKey> {
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
);
}
}
Loading