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 "$@"