secrets
1#!/usr/bin/env bash
2set -euo pipefail
3
4SECRETS_DIR="${SECRETS_DIR:-$HOME/projects/data}"
5IDENTITY_FILE="$HOME/.config/age/identity.txt"
6
7usage() {
8 cat >&2 <<'EOF'
9Usage: secrets <command> [args]
10
11Commands:
12 list List all secrets under $SECRETS_DIR, one path per
13 line relative to $SECRETS_DIR. Symlinks are shown as
14 "link -> target".
15 encrypt <file|name> Encrypt <file> to $SECRETS_DIR/<path>.age, or if
16 <file|name> is not an existing file, read plaintext
17 from stdin to $SECRETS_DIR/<path>.age
18 e.g. echo "value" | secrets encrypt my/tagged/secret
19 decrypt <secret> Decrypt $SECRETS_DIR/<secret>[.age] to stdout
20
21Paths are relative to $SECRETS_DIR; a leading "/" is optional and parent
22directories are created automatically on encrypt. To tag a secret, create a
23symlink to it (optionally named without the .age suffix); decrypt and list
24follow the link.
25
26Env:
27 SECRETS_DIR Secrets directory (default: ~/projects/data)
28EOF
29}
30
31die() { echo "secrets: $*" >&2; exit 1; }
32
33check_prereqs() {
34 command -v age >/dev/null 2>&1 || die "age not found in PATH"
35 command -v age-keygen >/dev/null 2>&1 || die "age-keygen not found in PATH"
36 [[ -d "$SECRETS_DIR" ]] || die "secrets directory not found: $SECRETS_DIR"
37 [[ -f "$IDENTITY_FILE" ]] || die "age identity file not found: $IDENTITY_FILE"
38}
39
40recipient() {
41 age-keygen -y "$IDENTITY_FILE"
42}
43
44# Normalize a user-supplied secret path into a path relative to SECRETS_DIR.
45# A leading "/" means "root of SECRETS_DIR". A trailing ".age" is stripped
46# (it is always re-added on disk). Empty and "." components are collapsed and
47# ".." is rejected, so the result can never escape SECRETS_DIR.
48normalize_secret_path() {
49 local p="${1#/}"
50 p="${p%.age}"
51 local comp out=()
52 local IFS=/
53 local comps
54 read -ra comps <<< "$p"
55 for comp in "${comps[@]}"; do
56 case "$comp" in
57 ''|'.') ;; # collapse "//" and "./"
58 '..') die "invalid secret path (contains '..'): $1" ;;
59 *) out+=("$comp") ;;
60 esac
61 done
62 ((${#out[@]})) || die "invalid secret path: $1"
63 local joined
64 printf -v joined '%s/' "${out[@]}"
65 printf '%s' "${joined%/}"
66}
67
68cmd_list() {
69 [[ $# -eq 0 ]] || { usage; die "list takes no arguments"; }
70 local f rel target
71 while IFS= read -r -d '' f; do
72 rel="${f#"$SECRETS_DIR"/}"
73 if [[ -L "$f" ]]; then
74 [[ -f "$f" ]] || continue # skip dangling links and links to directories
75 target="$(readlink "$f")"
76 printf '%s -> %s\n' "$rel" "$target"
77 else
78 printf '%s\n' "$rel"
79 fi
80 done < <(find "$SECRETS_DIR" \( -type f -name '*.age' -o -type l \) -print0) \
81 | LC_ALL=C sort
82}
83
84cmd_encrypt() {
85 [[ $# -eq 1 ]] || { usage; die "usage: secrets encrypt <file|name>"; }
86 local src="$1"
87 local rel dest pubkey
88 rel="$(normalize_secret_path "$src")"
89 dest="$SECRETS_DIR/${rel}.age"
90 [[ -e "$dest" || -L "$dest" ]] && die "refusing to overwrite existing file: $dest"
91 pubkey="$(recipient)"
92 # File mode: an existing regular file wins, preserving the relative path.
93 if [[ -f "$src" ]]; then
94 mkdir -p "$(dirname "$dest")"
95 age --encrypt -r "$pubkey" -o "$dest" "$src"
96 echo "encrypted $src -> $dest" >&2
97 return 0
98 fi
99 # Path exists but is not a regular file (e.g. a directory): fail
100 # rather than misinterpreting it as a secret name.
101 [[ -e "$src" ]] && die "input file not found (not a regular file): $src"
102 # Stdin mode: treat the argument as the secret path.
103 [[ -t 0 ]] && die "input file not found: $src (and no data on stdin)"
104 local tmp
105 tmp="$(mktemp)"
106 # Expand $tmp now: the local is gone by the time the RETURN trap fires.
107 # shellcheck disable=SC2064
108 trap "rm -f \"$tmp\"" RETURN
109 cat > "$tmp"
110 [[ -s "$tmp" ]] || die "no input: $src not found and stdin is empty"
111 mkdir -p "$(dirname "$dest")"
112 age --encrypt -r "$pubkey" -o "$dest" "$tmp"
113 echo "encrypted stdin -> $dest" >&2
114}
115
116cmd_decrypt() {
117 [[ $# -eq 1 ]] || { usage; die "usage: secrets decrypt <secret>"; }
118 local rel src
119 rel="$(normalize_secret_path "$1")"
120 src="$SECRETS_DIR/${rel}.age"
121 # Fall back to the exact path so tag symlinks need not end in .age.
122 [[ -f "$src" ]] || src="$SECRETS_DIR/${rel}"
123 [[ -f "$src" ]] || die "secret not found: $SECRETS_DIR/${rel}.age"
124 age --decrypt -i "$IDENTITY_FILE" "$src"
125}
126
127main() {
128 [[ $# -ge 1 ]] || { usage; exit 1; }
129 local cmd="$1"; shift
130 check_prereqs
131 case "$cmd" in
132 list) cmd_list "$@" ;;
133 encrypt) cmd_encrypt "$@" ;;
134 decrypt) cmd_decrypt "$@" ;;
135 -h|--help|help) usage; exit 0 ;;
136 *) echo "secrets: unknown command: $cmd" >&2; usage; exit 1 ;;
137 esac
138}
139
140main "$@"