5 files changed,
+409,
-0
A
LICENSE
+7,
-0
1@@ -0,0 +1,7 @@
2+Copyright (c) 2026 BT <[email protected]>
3+
4+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
5+
6+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
7+
8+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+123,
-0
1@@ -0,0 +1,123 @@
2+# secrets
3+
4+A tiny wrapper around [age](https://github.com/FiloSottile/age) for managing a
5+directory of encrypted secrets. Secrets are stored as individual `*.age` files
6+under a single directory (`$SECRETS_DIR`, default `~/projects/data`), and
7+paths are preserved so you can organize them in a hierarchy and expose one
8+secret under multiple tags using symlinks.
9+
10+## How it works
11+
12+- Encryption uses your age **identity** file to derive the public key; the
13+ identity lives at `~/.config/age/identity.txt` (not configurable).
14+- Secret paths are relative to `$SECRETS_DIR`. A leading `/` is optional, a
15+ trailing `.age` is optional and the suffix is always re-added on disk.
16+- Parent directories are created automatically on encrypt.
17+- `..` is rejected, so a secret path can never escape `$SECRETS_DIR`.
18+
19+## Requirements
20+
21+- `age` and `age-keygen` on `PATH`
22+- an existing `$SECRETS_DIR`
23+- an age identity at `~/.config/age/identity.txt`, e.g.
24+ ```sh
25+ mkdir -p ~/.config/age
26+ age-keygen -o ~/.config/age/identity.txt
27+ ```
28+
29+## Install
30+
31+With Nix (from the flake):
32+
33+```sh
34+nix build git+https://git.kilimanjaro.io/secrets
35+nix run git+https://git.kilimanjaro.io/secrets -- list
36+```
37+
38+Or as a flake input in a dev environment:
39+
40+```nix
41+inputs.secrets.url = "git+https://git.kilimanjaro.io/secrets";
42+# ...
43+buildInputs = [ secrets.packages.${system}.default ];
44+```
45+
46+Without Nix, just put the `secrets` script on your `PATH` (e.g. symlink it
47+into `~/bin`). The script only needs `age`, `age-keygen`, and coreutils.
48+
49+## Usage
50+
51+```sh
52+secrets list # list secrets, relative paths, symlinks annotated
53+secrets encrypt <file|name> # encrypt a file, or stdin, to $SECRETS_DIR/<path>.age
54+secrets decrypt <secret> # decrypt $SECRETS_DIR/<secret>[.age] to stdout
55+```
56+
57+### Encrypt
58+
59+From stdin, into a nested path (`openai/api-key.age` is created, along with
60+the `openai/` directory):
61+
62+```sh
63+echo "sk-abc123" | secrets encrypt openai/api-key
64+```
65+
66+From an existing file. The file's path is preserved under `$SECRETS_DIR`:
67+
68+```sh
69+secrets encrypt config/rclone.conf
70+# -> $SECRETS_DIR/config/rclone.conf.age
71+```
72+
73+A leading slash is allowed and just means "root of `$SECRETS_DIR`":
74+
75+```sh
76+echo "hunter2" | secrets encrypt /my/fancy/secret.age
77+# -> $SECRETS_DIR/my/fancy/secret.age
78+```
79+
80+Encrypt refuses to overwrite an existing secret.
81+
82+### Decrypt
83+
84+Decryption writes to stdout, so redirect it or pipe it where you need:
85+
86+```sh
87+secrets decrypt openai/api-key # -> sk-abc123
88+secrets decrypt openai/api-key.age # .age suffix is optional
89+secrets decrypt /my/fancy/secret.age # leading slash is optional too
90+```
91+
92+```sh
93+secrets decrypt config/rclone.conf > ~/.config/rclone/rclone.conf
94+```
95+
96+### List
97+
98+```sh
99+$ secrets list
100+config/rclone.conf.age
101+my/fancy/secret.age
102+openai/api-key.age
103+work/openai -> ../openai/api-key.age
104+```
105+
106+### Tagging with symlinks
107+
108+Store a secret once and link to it from as many tag directories as you like.
109+Symlink names may omit the `.age` suffix; `decrypt` falls back to the exact
110+path when `<name>.age` doesn't exist.
111+
112+```sh
113+ln -s ../openai/api-key.age "$SECRETS_DIR/work/openai"
114+ln -s ../openai/api-key.age "$SECRETS_DIR/personal/openai"
115+
116+secrets decrypt work/openai # -> sk-abc123
117+secrets list # shows "work/openai -> ../openai/api-key.age"
118+```
119+
120+## Environment
121+
122+| Variable | Default | Description |
123+| ------------- | -------------------- | ------------------------------------ |
124+| `SECRETS_DIR` | `~/projects/data` | Directory that holds the `*.age` files |
+61,
-0
1@@ -0,0 +1,61 @@
2+{
3+ "nodes": {
4+ "flake-utils": {
5+ "inputs": {
6+ "systems": "systems"
7+ },
8+ "locked": {
9+ "lastModified": 1731533236,
10+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
11+ "owner": "numtide",
12+ "repo": "flake-utils",
13+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
14+ "type": "github"
15+ },
16+ "original": {
17+ "owner": "numtide",
18+ "repo": "flake-utils",
19+ "type": "github"
20+ }
21+ },
22+ "nixpkgs": {
23+ "locked": {
24+ "lastModified": 1790578696,
25+ "narHash": "sha256-ZoxIApko70jCdbH3l20HWXOBaT2HZd87orzd2yJ9dVE=",
26+ "owner": "NixOS",
27+ "repo": "nixpkgs",
28+ "rev": "7a0f122f5090cf4c2ade2a13a0e229d4e19ba71f",
29+ "type": "github"
30+ },
31+ "original": {
32+ "owner": "NixOS",
33+ "ref": "nixos-unstable",
34+ "repo": "nixpkgs",
35+ "type": "github"
36+ }
37+ },
38+ "root": {
39+ "inputs": {
40+ "flake-utils": "flake-utils",
41+ "nixpkgs": "nixpkgs"
42+ }
43+ },
44+ "systems": {
45+ "locked": {
46+ "lastModified": 1681028828,
47+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
48+ "owner": "nix-systems",
49+ "repo": "default",
50+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
51+ "type": "github"
52+ },
53+ "original": {
54+ "owner": "nix-systems",
55+ "repo": "default",
56+ "type": "github"
57+ }
58+ }
59+ },
60+ "root": "root",
61+ "version": 7
62+}
+78,
-0
1@@ -0,0 +1,78 @@
2+{
3+ description = "secrets - age-encrypted secrets with a path-like, tag-friendly layout";
4+
5+ inputs = {
6+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
7+ flake-utils.url = "github:numtide/flake-utils";
8+ };
9+
10+ outputs =
11+ { self, nixpkgs, flake-utils }:
12+ flake-utils.lib.eachDefaultSystem (
13+ system:
14+ let
15+ pkgs = nixpkgs.legacyPackages.${system};
16+ lib = pkgs.lib;
17+
18+ version = "1.0.0";
19+
20+ secrets = pkgs.stdenvNoCC.mkDerivation {
21+ pname = "secrets";
22+ inherit version;
23+ src = ./.;
24+
25+ nativeBuildInputs = [ pkgs.makeWrapper ];
26+
27+ dontBuild = true;
28+
29+ installPhase = ''
30+ runHook preInstall
31+ install -Dm755 secrets $out/bin/secrets
32+ runHook postInstall
33+ '';
34+
35+ # The script shells out to age/age-keygen and coreutils/findutils
36+ # utilities. Bundle them on its PATH so it works in a bare dev env
37+ # without the consumer having to add them separately.
38+ postFixup = ''
39+ wrapProgram $out/bin/secrets \
40+ --prefix PATH : ${lib.makeBinPath [ pkgs.age pkgs.coreutils pkgs.findutils ]}
41+ '';
42+
43+ meta = with lib; {
44+ description = "Manage age-encrypted secrets with a path-like, tag-friendly layout";
45+ homepage = "https://git.kilimanjaro.io/secrets";
46+ license = licenses.mit;
47+ mainProgram = "secrets";
48+ platforms = platforms.all;
49+ };
50+ };
51+ in
52+ {
53+ packages = {
54+ inherit secrets;
55+ default = secrets;
56+ };
57+
58+ apps.default = flake-utils.lib.mkApp {
59+ drv = secrets;
60+ exePath = "/bin/secrets";
61+ };
62+
63+ # `nixpkgs.overlays = [ secrets.overlays.default ];` then use pkgs.secrets.
64+ overlays.default = final: _prev: {
65+ secrets = self.packages.${final.stdenv.hostPlatform.system}.default;
66+ };
67+
68+ # For hacking on the script itself. Consumers who only want the tool
69+ # should add `secrets.packages.${system}.default` to their own shell.
70+ devShells.default = pkgs.mkShell {
71+ packages = [
72+ secrets
73+ pkgs.age
74+ pkgs.shellcheck
75+ ];
76+ };
77+ }
78+ );
79+}
A
secrets
+140,
-0
1@@ -0,0 +1,140 @@
2+#!/usr/bin/env bash
3+set -euo pipefail
4+
5+SECRETS_DIR="${SECRETS_DIR:-$HOME/projects/data}"
6+IDENTITY_FILE="$HOME/.config/age/identity.txt"
7+
8+usage() {
9+ cat >&2 <<'EOF'
10+Usage: secrets <command> [args]
11+
12+Commands:
13+ list List all secrets under $SECRETS_DIR, one path per
14+ line relative to $SECRETS_DIR. Symlinks are shown as
15+ "link -> target".
16+ encrypt <file|name> Encrypt <file> to $SECRETS_DIR/<path>.age, or if
17+ <file|name> is not an existing file, read plaintext
18+ from stdin to $SECRETS_DIR/<path>.age
19+ e.g. echo "value" | secrets encrypt my/tagged/secret
20+ decrypt <secret> Decrypt $SECRETS_DIR/<secret>[.age] to stdout
21+
22+Paths are relative to $SECRETS_DIR; a leading "/" is optional and parent
23+directories are created automatically on encrypt. To tag a secret, create a
24+symlink to it (optionally named without the .age suffix); decrypt and list
25+follow the link.
26+
27+Env:
28+ SECRETS_DIR Secrets directory (default: ~/projects/data)
29+EOF
30+}
31+
32+die() { echo "secrets: $*" >&2; exit 1; }
33+
34+check_prereqs() {
35+ command -v age >/dev/null 2>&1 || die "age not found in PATH"
36+ command -v age-keygen >/dev/null 2>&1 || die "age-keygen not found in PATH"
37+ [[ -d "$SECRETS_DIR" ]] || die "secrets directory not found: $SECRETS_DIR"
38+ [[ -f "$IDENTITY_FILE" ]] || die "age identity file not found: $IDENTITY_FILE"
39+}
40+
41+recipient() {
42+ age-keygen -y "$IDENTITY_FILE"
43+}
44+
45+# Normalize a user-supplied secret path into a path relative to SECRETS_DIR.
46+# A leading "/" means "root of SECRETS_DIR". A trailing ".age" is stripped
47+# (it is always re-added on disk). Empty and "." components are collapsed and
48+# ".." is rejected, so the result can never escape SECRETS_DIR.
49+normalize_secret_path() {
50+ local p="${1#/}"
51+ p="${p%.age}"
52+ local comp out=()
53+ local IFS=/
54+ local comps
55+ read -ra comps <<< "$p"
56+ for comp in "${comps[@]}"; do
57+ case "$comp" in
58+ ''|'.') ;; # collapse "//" and "./"
59+ '..') die "invalid secret path (contains '..'): $1" ;;
60+ *) out+=("$comp") ;;
61+ esac
62+ done
63+ ((${#out[@]})) || die "invalid secret path: $1"
64+ local joined
65+ printf -v joined '%s/' "${out[@]}"
66+ printf '%s' "${joined%/}"
67+}
68+
69+cmd_list() {
70+ [[ $# -eq 0 ]] || { usage; die "list takes no arguments"; }
71+ local f rel target
72+ while IFS= read -r -d '' f; do
73+ rel="${f#"$SECRETS_DIR"/}"
74+ if [[ -L "$f" ]]; then
75+ [[ -f "$f" ]] || continue # skip dangling links and links to directories
76+ target="$(readlink "$f")"
77+ printf '%s -> %s\n' "$rel" "$target"
78+ else
79+ printf '%s\n' "$rel"
80+ fi
81+ done < <(find "$SECRETS_DIR" \( -type f -name '*.age' -o -type l \) -print0) \
82+ | LC_ALL=C sort
83+}
84+
85+cmd_encrypt() {
86+ [[ $# -eq 1 ]] || { usage; die "usage: secrets encrypt <file|name>"; }
87+ local src="$1"
88+ local rel dest pubkey
89+ rel="$(normalize_secret_path "$src")"
90+ dest="$SECRETS_DIR/${rel}.age"
91+ [[ -e "$dest" || -L "$dest" ]] && die "refusing to overwrite existing file: $dest"
92+ pubkey="$(recipient)"
93+ # File mode: an existing regular file wins, preserving the relative path.
94+ if [[ -f "$src" ]]; then
95+ mkdir -p "$(dirname "$dest")"
96+ age --encrypt -r "$pubkey" -o "$dest" "$src"
97+ echo "encrypted $src -> $dest" >&2
98+ return 0
99+ fi
100+ # Path exists but is not a regular file (e.g. a directory): fail
101+ # rather than misinterpreting it as a secret name.
102+ [[ -e "$src" ]] && die "input file not found (not a regular file): $src"
103+ # Stdin mode: treat the argument as the secret path.
104+ [[ -t 0 ]] && die "input file not found: $src (and no data on stdin)"
105+ local tmp
106+ tmp="$(mktemp)"
107+ # Expand $tmp now: the local is gone by the time the RETURN trap fires.
108+ # shellcheck disable=SC2064
109+ trap "rm -f \"$tmp\"" RETURN
110+ cat > "$tmp"
111+ [[ -s "$tmp" ]] || die "no input: $src not found and stdin is empty"
112+ mkdir -p "$(dirname "$dest")"
113+ age --encrypt -r "$pubkey" -o "$dest" "$tmp"
114+ echo "encrypted stdin -> $dest" >&2
115+}
116+
117+cmd_decrypt() {
118+ [[ $# -eq 1 ]] || { usage; die "usage: secrets decrypt <secret>"; }
119+ local rel src
120+ rel="$(normalize_secret_path "$1")"
121+ src="$SECRETS_DIR/${rel}.age"
122+ # Fall back to the exact path so tag symlinks need not end in .age.
123+ [[ -f "$src" ]] || src="$SECRETS_DIR/${rel}"
124+ [[ -f "$src" ]] || die "secret not found: $SECRETS_DIR/${rel}.age"
125+ age --decrypt -i "$IDENTITY_FILE" "$src"
126+}
127+
128+main() {
129+ [[ $# -ge 1 ]] || { usage; exit 1; }
130+ local cmd="$1"; shift
131+ check_prereqs
132+ case "$cmd" in
133+ list) cmd_list "$@" ;;
134+ encrypt) cmd_encrypt "$@" ;;
135+ decrypt) cmd_decrypt "$@" ;;
136+ -h|--help|help) usage; exit 0 ;;
137+ *) echo "secrets: unknown command: $cmd" >&2; usage; exit 1 ;;
138+ esac
139+}
140+
141+main "$@"