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 a009de1 commit 9fafb74
Show file tree
Hide file tree
Showing 20 changed files with 1,150 additions and 49 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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ See [Tools](/dev-tools/).

### `[env]` - Arbitrary Environment Variables

See [environments](/environments).
See [environments](/environments/).

### `[tasks.*]` - Run files or shell scripts

Expand Down
2 changes: 1 addition & 1 deletion docs/dev-tools/comparison-to-asdf.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ It will not, however, reuse existing asdf directories
Casual users coming from asdf have generally found mise to just be a faster, easier to use asdf.

:::tip
Make sure you have a look at [environments](/environments.html) and [tasks](/tasks/) which
Make sure you have a look at [environments](/environments/) and [tasks](/tasks/) which
are major portions of mise that have no asdf equivalent.
:::

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" />
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ echo '~/.local/bin/mise activate fish | source' >> ~/.config/fish/config.fish
## 3. Using `mise`

:::info
Of course, if using mise solely for [environment management](/environments)
Of course, if using mise solely for [environment management](/environments/)
or [running tasks](/tasks/)
this step is not necessary. You can use it to make sure `mise` is correctly setup.
:::
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Plugins in mise extend functionality. Historically they were the only way to add
that backend works is every tool has its own plugin which needs to be manually installed. However now with core languages and
backends like aqua/ubi, plugins are no longer necessary to run most tools in mise.

Meanwhile, plugins have expanded beyond tools and can provide functionality like [setting env vars globally](/environments.html#plugin-provided-env-directives) without relying on a tool being installed.
Meanwhile, plugins have expanded beyond tools and can provide functionality like [setting env vars globally](/environments/#plugin-provided-env-directives) without relying on a tool being installed.

Tool plugins should be avoided for security reasons. New tools will not be accepted into mise built with asdf/vfox plugins unless they are very popular and
aqua/ubi is not an option for some reason.
Expand Down
4 changes: 2 additions & 2 deletions docs/walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ _.path = "./node_modules/.bin"
This will add `./node_modules/.bin` to the PATH for the project—with "." here referring to the directory
the `mise.toml` file is in so if you enter a subdirectory, it will still work.

_See [Environments](/environments) for more information on working with environment variables._
_See [Environments](/environments/) for more information on working with environment variables._

## Tasks

Expand Down Expand Up @@ -212,7 +212,7 @@ programming languages or tools used on it.
For further reading:

- [Dev Tools](/dev-tools/) – A deeper overview of working with dev tools
- [Environments](/environments) – A deeper overview of working with environment variables
- [Environments](/environments/) – A deeper overview of working with environment variables
- [Tasks](/tasks/) – A deeper overview of working with tasks
- [Configuration](/configuration) – More information on `mise.toml` files
- [Settings](/configuration/settings) – All the configuration settings available in mise
Expand Down
1 change: 1 addition & 0 deletions e2e/backend/test_pipx_deep_dependencies
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016,SC2034
export MISE_PIPX_UVX=0

# Create system "tools" that always fail and push them to the front of PATH
cat >"$HOME/bin/fail" <<'EOF'
Expand Down
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
set -x

mise use sops age
age="$(mise x -- 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
mise x -- sops encrypt -i --age "$age_pub" .env.json
assert "mise set _.file=.env.json"
assert_contains "mise env" "export SECRET=mysecret"

# yaml
age_pub="$(mise x -- age-keygen -o ~/age.txt 2>&1 | awk '{print $3}')"
mise settings set sops.age_key_file "~/age.txt"
echo 'SECRET: mysecret' >.env.yaml
mise x -- sops encrypt -i --age "$age_pub" .env.yaml
export MISE_SOPS_AGE_KEY=
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 @@ -658,6 +658,23 @@
"description": "Suppress all `mise run|watch` output except errors—including what tasks output.",
"type": "boolean"
},
"sops": {
"additionalProperties": false,
"properties": {
"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"
},
"age_recipients": {
"description": "The age public keys to use for sops secret encryption.",
"type": "string"
}
}
},
"status": {
"additionalProperties": false,
"properties": {
Expand Down
21 changes: 21 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,25 @@ 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
default_docs = "~/.config/mise/age.key"
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
80 changes: 72 additions & 8 deletions src/config/env_directive/file.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
use crate::config::env_directive::EnvResults;
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};

// use indexmap so source is after value for `mise env --json` output
type EnvMap = IndexMap<String, String>;

#[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 +30,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
Loading

0 comments on commit 9fafb74

Please sign in to comment.