Skip to content

Commit

Permalink
feat: encrypted configs
Browse files Browse the repository at this point in the history
  • Loading branch information
jdx committed Dec 15, 2024
1 parent 2ca0726 commit 6d0b617
Show file tree
Hide file tree
Showing 14 changed files with 1,135 additions and 43 deletions.
896 changes: 867 additions & 29 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ rayon = "1"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "zstd"] }
rmp-serde = "1"
rops = {version="0.1", default-features = false, features = ["aes-gcm", "sha2", "yaml", "json", "age"]}
serde = "1"
serde_derive = "1"
serde_ignored = "0.1"
Expand Down
3 changes: 2 additions & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineConfig({
outline: "deep",
nav: [
{ text: "Dev Tools", link: "/dev-tools/" },
{ text: "Environments", link: "/environments" },
{ text: "Environments", link: "/environments/" },
{ text: "Tasks", link: "/tasks/" },
],
sidebar: [
Expand Down Expand Up @@ -102,6 +102,7 @@ export default defineConfig({
text: "Environments",
items: [
{ text: "Environment variables", link: "/environments/" },
{ text: "Secrets", link: "/environments/secrets" },
{ text: "Hooks", link: "/hooks" },
{ text: "direnv", link: "/direnv" },
],
Expand Down
4 changes: 2 additions & 2 deletions docs/environments.md → docs/environments/index.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Environments

> Like [direnv](https://github.com/direnv/direnv) it
manages *environment variables* for
different project directories.
> manages _environment variables_ for
> different project directories.
Use mise to specify environment variables used for different projects. Create a `mise.toml` file
in the root of your project directory:
Expand Down
77 changes: 77 additions & 0 deletions docs/environments/secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Secrets

Because env vars in mise.toml can store sensitive information, mise has built-in support for reading
encrypted secrets from files. Currently, this is done with a [sops](https://getsops.com) implementation
however other secret backends could be added in the future.

Secrets are `.env.(json|yaml|toml)` files with a simple structure, for example:

```json
{
"AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
"AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}
```

Env vars from this can be imported into a mise config with the following:

```toml
[env]
_.file = ".env.json"
```

mise will automatically use a secret backend like sops if the file is encrypted.

## sops

mise uses the rust [rops](https://github.com/gibbz00/rops) library to interact with [sops](https://getsops.com) files.
If you encrypt a sops file, mise will automatically decrypt it when reading the file. sops files can
be in json, yaml, or toml format—however if you want to use toml you'll need to use the rops cli instead
of sops. Otherwise, either sops or rops will work fine.

::: info
Currently age is the only sops encryption method supported.
:::

In order to encrypt a file with sops, you'll first need to install it (`mise use -g sops`). You'll
also need to install [age](https://github.com/FiloSottile/age) (`mise use -g age`) to generate a keypair for sops to use
if you have not already done so.

To generate a keypair with age run the following and note the public key that is output to use
in the next command to `sops`:

```sh
$ age-keygen -o ~/.config/mise/age.txt
Public key: <public key>
```

Assuming we have a `.env.json` file like at the top of this doc, we can now encrypt it with sops:

```sh
sops encrypt -i --age "<public key>" .env.json
```

::: tip
The `-i` here overwrites the file with an encrypted version. This encrypted version is safe to commit
into your repo as without the private key (`~/.config/mise/age.txt` in this case) the file is useless.

You can later decrypt the file with `sops decrypt -i .env.json` or edit it in EDITOR with `sops edit .env.json`.
However, you'll first need to set SOPS_AGE_KEY_FILE to `~/.config/mise/age.txt` to decrypt the file.
:::

Lastly, we need to add the file to our mise config which can be done with `mise set _.file=.env.json`.

Now when you run `mise env` you should see the env vars from the file:

```sh
$ mise env
export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
```

### `sops` Settings

<script setup>
import Settings from '/components/settings.vue';
</script>
<Settings child="sops" :level="3" />
23 changes: 23 additions & 0 deletions e2e/secrets/test_secrets
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash

#mise use sops
age="$(age-keygen 2>&1)"
age_pub="$(echo "$age" | grep "# public key:" | awk '{print $4}')"
MISE_SOPS_AGE_KEY="$(echo "$age" | grep "AGE-SECRET-KEY")"
export MISE_SOPS_AGE_KEY

# json
echo '{ "SECRET": "mysecret" }' >.env.json
rops encrypt -i --age "$age_pub" .env.json
assert "mise set _.file=.env.json"
assert_contains "mise env" "export SECRET=mysecret"

export MISE_SOPS_AGE_KEY=
mise settings set sops.age_key_file "~/age.txt"
age_pub="$(age-keygen -o ~/age.txt 2>&1 | awk '{print $3}')"

# yaml
echo 'SECRET: mysecret' >.env.yaml
rops encrypt -i --age "$age_pub" .env.yaml
assert "mise set _.file=.env.yaml"
assert_contains "mise env" "export SECRET=mysecret"
17 changes: 17 additions & 0 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,23 @@
}
}
},
"sops": {
"additionalProperties": false,
"properties": {
"age_recipients": {
"description": "The age public keys to use for sops secret encryption.",
"type": "string"
},
"age_key": {
"description": "The age private key to use for sops secret decryption.",
"type": "string"
},
"age_key_file": {
"description": "Path to the age private key file to use for sops secret decryption.",
"type": "string"
}
}
},
"pipx_uvx": {
"description": "Use uvx instead of pipx if uv is installed and on PATH.",
"type": "boolean"
Expand Down
20 changes: 20 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ passing `--fuzzy` on the command line.
env = "MISE_PIPX_UVX"
type = "Bool"
description = "Use uvx instead of pipx if uv is installed and on PATH."
optional = true
default_docs = "true"
docs = """
If true, mise will use `uvx` instead of `pipx` if
[`uv`](https://docs.astral.sh/uv/) is installed and on PATH.
Expand Down Expand Up @@ -856,6 +858,24 @@ env = "MISE_SILENT"
type = "Bool"
description = "Suppress all `mise run|watch` output except errors—including what tasks output."

[sops.age_key]
env = "MISE_SOPS_AGE_KEY"
type = "String"
optional = true
description = "The age private key to use for sops secret decryption."

[sops.age_key_file]
env = "MISE_SOPS_AGE_KEY_FILE"
type = "Path"
optional = true
description = "Path to the age private key file to use for sops secret decryption."

[sops.age_recipients]
env = "MISE_SOPS_AGE_RECIPIENTS"
type = "String"
optional = true
description = "The age public keys to use for sops secret encryption."

[status.missing_tools]
env = "MISE_STATUS_MESSAGE_MISSING_TOOLS"
type = "String"
Expand Down
8 changes: 6 additions & 2 deletions src/backend/pipx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ impl Backend for PIPXBackend {
.parse::<PipxRequest>()?
.pipx_request(&tv.version, &tv.request.options());

if SETTINGS.pipx.uvx {
if self.uv_is_installed() && SETTINGS.pipx.uvx != Some(false) {
ctx.pr
.set_message(format!("uv tool install {pipx_request}"));
let mut cmd = Self::uvx_cmd(
Expand Down Expand Up @@ -141,7 +141,7 @@ impl PIPXBackend {
.into_iter()
.filter(|(b, _tv)| b.ba().backend_type() == BackendType::Pipx)
.collect_vec();
if SETTINGS.pipx.uvx {
if SETTINGS.pipx.uvx != Some(false) {
let pr = MultiProgressReport::get().add("reinstalling pipx tools with uvx");
for (b, tv) in pipx_tools {
for (cmd, tool) in &[
Expand Down Expand Up @@ -203,6 +203,10 @@ impl PIPXBackend {
.prepend_path(vec![tv.install_path().join("bin")])?
.prepend_path(b.dependency_toolset()?.list_paths())
}

fn uv_is_installed(&self) -> bool {
self.dependency_which("uv").is_some()
}
}

enum PipxRequest {
Expand Down
78 changes: 70 additions & 8 deletions src/config/env_directive/file.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
use crate::config::env_directive::EnvResults;
use crate::env_diff::EnvMap;
use crate::file::display_path;
use crate::Result;
use crate::{file, sops, Result};
use eyre::{eyre, WrapErr};
use indexmap::IndexMap;
use rops::file::format::{JsonFileFormat, YamlFileFormat};
use std::path::{Path, PathBuf};

#[derive(serde::Serialize, serde::Deserialize)]
struct Env<V> {
#[serde(default)]
sops: IndexMap<String, V>,
#[serde(flatten)]
env: EnvMap,
}

impl EnvResults {
pub fn file(
ctx: &mut tera::Context,
Expand All @@ -18,15 +28,67 @@ impl EnvResults {
let s = r.parse_template(ctx, source, input.to_string_lossy().as_ref())?;
for p in xx::file::glob(normalize_path(config_root, s.into())).unwrap_or_default() {
r.env_files.push(p.clone());
let errfn = || eyre!("failed to parse dotenv file: {}", display_path(&p));
if let Ok(dotenv) = dotenvy::from_path_iter(&p) {
for item in dotenv {
let (k, v) = item.wrap_err_with(errfn)?;
r.env_remove.remove(&k);
env.insert(k, (v, Some(p.clone())));
}
let parse_template = |s: String| r.parse_template(ctx, source, &s);
let ext = p
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_default();
let new_vars = match ext.as_str() {
"json" => Self::json(&p, parse_template)?,
"yaml" => Self::yaml(&p, parse_template)?,
_ => Self::dotenv(&p)?,
};
for (k, v) in new_vars {
r.env_remove.remove(&k);
env.insert(k, (v, Some(p.clone())));
}
}
Ok(())
}

fn json<PT>(p: &Path, parse_template: PT) -> Result<EnvMap>
where
PT: Fn(String) -> Result<String>,
{
let errfn = || eyre!("failed to parse json file: {}", display_path(p));
if let Ok(raw) = file::read_to_string(p) {
let mut f: Env<serde_json::Value> = serde_json::from_str(&raw).wrap_err_with(errfn)?;
if !f.sops.is_empty() {
let raw = sops::decrypt::<_, JsonFileFormat>(&raw, parse_template)?;
f = serde_json::from_str(&raw).wrap_err_with(errfn)?;
}
Ok(f.env)
} else {
Ok(EnvMap::new())
}
}

fn yaml<PT>(p: &Path, parse_template: PT) -> Result<EnvMap>
where
PT: Fn(String) -> Result<String>,
{
let errfn = || eyre!("failed to parse yaml file: {}", display_path(p));
if let Ok(raw) = file::read_to_string(p) {
let mut f: Env<serde_yaml::Value> = serde_yaml::from_str(&raw).wrap_err_with(errfn)?;
if !f.sops.is_empty() {
let raw = sops::decrypt::<_, YamlFileFormat>(&raw, parse_template)?;
f = serde_yaml::from_str(&raw).wrap_err_with(errfn)?;
}
Ok(f.env)
} else {
Ok(EnvMap::new())
}
}

fn dotenv(p: &Path) -> Result<EnvMap> {
let errfn = || eyre!("failed to parse dotenv file: {}", display_path(p));
let mut env = EnvMap::new();
if let Ok(dotenv) = dotenvy::from_path_iter(p) {
for item in dotenv {
let (k, v) = item.wrap_err_with(errfn)?;
env.insert(k, v);
}
}
Ok(env)
}
}
1 change: 1 addition & 0 deletions src/config/env_directive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ impl Display for EnvDirective {
}
}

#[derive(Clone)]
pub struct EnvResults {
pub env: IndexMap<String, (String, PathBuf)>,
pub env_remove: BTreeSet<String>,
Expand Down
2 changes: 1 addition & 1 deletion src/config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ impl Settings {
self.cargo.binstall = cargo_binstall;
}
if let Some(pipx_uvx) = self.pipx_uvx {
self.pipx.uvx = pipx_uvx;
self.pipx.uvx = Some(pipx_uvx);
}
if let Some(python_compile) = self.python_compile {
self.python.compile = Some(python_compile);
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ mod runtime_symlinks;
mod shell;
mod shims;
mod shorthands;
mod sops;
pub(crate) mod task;
pub(crate) mod tera;
pub(crate) mod timeout;
Expand Down
47 changes: 47 additions & 0 deletions src/sops.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
use crate::config::SETTINGS;
use crate::file::replace_path;
use crate::{file, result};
use eyre::WrapErr;
use rops::cryptography::cipher::AES256GCM;
use rops::cryptography::hasher::SHA512;
use rops::file::state::EncryptedFile;
use rops::file::RopsFile;
use std::env;

pub fn decrypt<PT, F>(input: &str, parse_template: PT) -> result::Result<String>
where
PT: Fn(String) -> result::Result<String>,
F: rops::file::format::FileFormat,
{
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
if let Some(p) = &SETTINGS.sops.age_key_file {
let p = match parse_template(p.to_string_lossy().to_string()) {
Ok(p) => p,
Err(e) => {
warn!("failed to parse sops age key file: {}", e);
return;
}
};
if let Ok(raw) = file::read_to_string(replace_path(p)) {
let key = raw
.trim()
.lines()
.filter(|l| !l.starts_with('#'))
.collect::<String>();
env::set_var("ROPS_AGE", key);
}
}
if let Some(age_key) = &SETTINGS.sops.age_key {
if !age_key.is_empty() {
env::set_var("ROPS_AGE", age_key);
}
}
});
let f = input
.parse::<RopsFile<EncryptedFile<AES256GCM, SHA512>, F>>()
.wrap_err("failed to parse sops file")?;
Ok(f.decrypt::<F>()
.wrap_err("failed to decrypt sops file")?
.to_string())
}

0 comments on commit 6d0b617

Please sign in to comment.