Adversarial review of the diff surfaced 6 non-critical findings, all fixed: - pull now warns + reports when a merge fails (read-only $HOME, full disk) instead of silently exiting 0 as if it synced - push skips a multi-line (continuation) managed value with a warning rather than writing a dangling backslash that would corrupt the target - absent-key pull check escapes regex '.' so systemProp.foo can't false-match - fix garbled 'Merged 0\n0 key(s)' count when a blob has no '=' lines - slug gets a cksum suffix so paths that clean to the same string (a/b vs a_b) don't overwrite each other's blob - docs: backup happens before each merge, not just the first Tests: 112 -> 113 (continuation-value skip).
1467 lines
48 KiB
Bash
Executable file
1467 lines
48 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
|
|
# secrets — encrypted env file sync between machines
|
|
# Uses age key-file encryption + a private git repo.
|
|
|
|
# F5: explicit HOME check. `set -u` would already error if HOME were unset,
|
|
# but the message would be cryptic ("HOME: unbound variable" pointing at
|
|
# the SECRETS_DIR default-init line). Check up front with a directed message
|
|
# so cron/sudo/CI users know what to fix.
|
|
: "${HOME:?HOME is not set; secrets needs a home directory to find or create the store}"
|
|
|
|
# Capture the user-provided SECRETS_DIR (if any) before defaulting.
|
|
# resolve_store() uses this to honor SECRETS_DIR as the legacy escape hatch
|
|
# while letting .secrets-store files take precedence per project.
|
|
_USER_SECRETS_DIR="${SECRETS_DIR:-}"
|
|
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
|
|
KEY_FILE="$SECRETS_DIR/key.txt"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
# Set by resolve_store(). Reports which rule chose SECRETS_DIR.
|
|
STORE_SOURCE="default"
|
|
# Path to the .secrets-store file that won resolution, if any.
|
|
_LAST_FOUND_AT=""
|
|
# Remote URL parsed from the active .secrets-store file (optional 2nd token).
|
|
# Used by check_initialized to fill in a runnable `git clone <URL> <PATH>`
|
|
# in the missing-store error so teammates don't have to ask.
|
|
_REMOTE_URL=""
|
|
# --store flag value, captured by the pre-pass.
|
|
STORE_OVERRIDE=""
|
|
|
|
# ─── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
|
info() { echo "==> $*"; }
|
|
|
|
check_cmd() {
|
|
command -v "$1" >/dev/null 2>&1 || die "'$1' is not installed. Run: brew install $1"
|
|
}
|
|
|
|
check_initialized() {
|
|
if [ -d "$SECRETS_DIR/.git" ]; then
|
|
return
|
|
fi
|
|
if [ "$STORE_SOURCE" != "default" ]; then
|
|
# Fill in the remote URL when .secrets-store provided one, so the
|
|
# teammate can copy-paste the clone command without asking the original
|
|
# setter for the URL. Falls back to a placeholder otherwise.
|
|
local clone_url
|
|
if [ -n "${_REMOTE_URL:-}" ]; then
|
|
clone_url="$_REMOTE_URL"
|
|
else
|
|
clone_url="<their-store-remote>"
|
|
fi
|
|
die "Store not initialized: $SECRETS_DIR
|
|
Resolved from: $STORE_SOURCE
|
|
This path doesn't exist on this machine yet.
|
|
|
|
If you're joining a teammate's existing store:
|
|
git clone $clone_url $SECRETS_DIR
|
|
# then copy their key.txt to $SECRETS_DIR/key.txt
|
|
|
|
If you want a fresh new store at this path:
|
|
secrets --store $SECRETS_DIR init"
|
|
fi
|
|
die "Not initialized. Run: secrets init"
|
|
}
|
|
|
|
check_key() {
|
|
if [ -f "$KEY_FILE" ]; then
|
|
return
|
|
fi
|
|
if [ "$STORE_SOURCE" != "default" ]; then
|
|
die "Key file not found at $KEY_FILE
|
|
The store at $SECRETS_DIR exists but has no key.txt.
|
|
Resolved from: $STORE_SOURCE
|
|
|
|
If joining a teammate's store: copy their key.txt to $KEY_FILE.
|
|
If this is your own new store: re-run init for this store path."
|
|
fi
|
|
die "Key file not found at $KEY_FILE. Run: secrets init"
|
|
}
|
|
|
|
get_pubkey() {
|
|
age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
|
|
}
|
|
|
|
derive_project_name() {
|
|
local explicit="${1:-}"
|
|
if [ -n "$explicit" ]; then
|
|
echo "$explicit"
|
|
return
|
|
fi
|
|
# Try git remote basename
|
|
local remote
|
|
remote=$(git config --get remote.origin.url 2>/dev/null || true)
|
|
if [ -n "$remote" ]; then
|
|
basename "$remote" .git
|
|
return
|
|
fi
|
|
# Fall back to current directory name
|
|
basename "$PWD"
|
|
}
|
|
|
|
# ─── Store resolution ──────────────────────────────────────────────────
|
|
#
|
|
# Resolution order (highest precedence first):
|
|
# 1. --store <dir> flag (captured by main pre-pass into STORE_OVERRIDE)
|
|
# 2. .secrets-store file in cwd or any ancestor up to but NOT including $HOME
|
|
# 3. SECRETS_DIR env var (legacy escape hatch)
|
|
# 4. $HOME/.secrets default
|
|
#
|
|
# .secrets-store file format: first non-empty non-comment line is the store
|
|
# spec, optionally followed by a remote URL on the same line. Spec is one of:
|
|
# - absolute path (/Users/you/.secrets-work)
|
|
# - ~/path (expanded to $HOME/path)
|
|
# - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets)
|
|
#
|
|
# The optional second token (whitespace-separated) is a git remote URL. It
|
|
# is only used as a hint when the store directory does not yet exist on
|
|
# the current machine — the missing-store error includes a runnable
|
|
# `git clone <url> <path>` for the teammate to copy. Examples:
|
|
#
|
|
# work
|
|
# work git@github.com:acme/work-secrets.git
|
|
# ~/.secrets-work https://github.com/acme/work-secrets.git
|
|
#
|
|
# Comments (#) and CRLF line endings are tolerated. NO shell expansion is
|
|
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
|
|
# literal characters to prevent code injection from a committed file.
|
|
|
|
# Take a store spec (path or bare name) and return an absolute directory.
|
|
_expand_store_path() {
|
|
local path="$1"
|
|
case "$path" in
|
|
/*)
|
|
echo "$path"
|
|
;;
|
|
'~')
|
|
echo "$HOME"
|
|
;;
|
|
'~/'*)
|
|
echo "$HOME/${path#\~/}"
|
|
;;
|
|
.*|*/*)
|
|
# Relative path or path containing /. Treat as path relative to cwd.
|
|
echo "$path"
|
|
;;
|
|
default)
|
|
# Sugar: --store default → the canonical default store
|
|
echo "$HOME/.secrets"
|
|
;;
|
|
*)
|
|
# Bare name like "work" → $HOME/.secrets-work
|
|
echo "$HOME/.secrets-$path"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Read a .secrets-store file. On success, print "<store-spec>\t<remote-url>"
|
|
# (URL empty if not provided or rejected as unsafe). The first non-empty
|
|
# non-comment line is the active line; the first whitespace splits it into
|
|
# the spec and an optional URL.
|
|
#
|
|
# Security: the URL is later substituted into a copy-paste-ready `git clone`
|
|
# command in the missing-store error. An attacker who slips a malicious line
|
|
# into a committed .secrets-store could weaponize that copy-paste — e.g.
|
|
# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...`
|
|
# and a teammate following the directed error would execute the payload.
|
|
# We reject URLs containing shell metacharacters, control characters, ANSI
|
|
# escapes, and embedded whitespace. Rejected URLs are dropped silently from
|
|
# the parser's perspective (a warning is printed to stderr); the resolver
|
|
# falls back to the `<their-store-remote>` placeholder so the directed
|
|
# error stays useful without rendering the attacker-controlled string.
|
|
_parse_secrets_store_file() {
|
|
local file="$1"
|
|
local line
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
# Strip CRLF
|
|
line="${line%$'\r'}"
|
|
# Trim leading whitespace
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
# Trim trailing whitespace
|
|
line="${line%"${line##*[![:space:]]}"}"
|
|
[ -z "$line" ] && continue
|
|
case "$line" in '#'*) continue ;; esac
|
|
# Split into spec (first whitespace-separated token) and url (rest of
|
|
# the line, verbatim). `read -r` does NOT glob-expand and preserves the
|
|
# tail in $rest as a single string — important so `work *` doesn't
|
|
# silently expand to `work file1 file2 ...`.
|
|
local spec="" rest=""
|
|
read -r spec rest <<< "$line"
|
|
local url="$rest"
|
|
if [ -n "$url" ]; then
|
|
# Reject URLs containing characters that could weaponize a
|
|
# copy-paste shell command, terminal escapes, or be ambiguous.
|
|
# Match order: control chars (incl. ESC \x1b), shell meta, whitespace.
|
|
case "$url" in
|
|
*[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*" "*)
|
|
echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2
|
|
url=""
|
|
;;
|
|
esac
|
|
fi
|
|
printf '%s\t%s\n' "$spec" "$url"
|
|
return 0
|
|
done < "$file"
|
|
return 1
|
|
}
|
|
|
|
# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never
|
|
# walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp),
|
|
# the walk does not run. Symlinks are resolved with `cd -P`.
|
|
# On success: prints "<expanded-store-dir>\t<source-file-path>\t<remote-url>"
|
|
# (URL empty if .secrets-store didn't include one) and returns 0. The caller
|
|
# (resolve_store) splits the tab-separated 3-tuple. We can't set a
|
|
# parent-shell variable from here because we're typically called inside
|
|
# `$(...)` command substitution, which runs in a subshell.
|
|
_find_secrets_store_file() {
|
|
local dir
|
|
dir=$(pwd -P 2>/dev/null) || dir="$PWD"
|
|
local home_resolved
|
|
home_resolved=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_resolved="$HOME"
|
|
|
|
while [ -n "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "$home_resolved" ]; do
|
|
# Bound: only walk while we're strictly below $HOME.
|
|
case "$dir" in
|
|
"$home_resolved"/*) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
|
|
# F2: never follow a symlinked .secrets-store. A committed symlink
|
|
# could point at any user-readable file (~/.aws/credentials, /etc/passwd)
|
|
# and trick the resolver into reading attacker-chosen content.
|
|
if [ -L "$dir/.secrets-store" ]; then
|
|
:
|
|
elif [ -f "$dir/.secrets-store" ]; then
|
|
local parsed
|
|
if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then
|
|
# parsed is "<spec>\t<url>" (URL may be empty)
|
|
local spec="${parsed%%$'\t'*}"
|
|
local url="${parsed#*$'\t'}"
|
|
local expanded
|
|
expanded=$(_expand_store_path "$spec")
|
|
printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
|
|
return 0
|
|
fi
|
|
fi
|
|
dir=$(dirname "$dir")
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
|
|
# Sets STORE_SOURCE to one of:
|
|
# "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
|
|
# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store
|
|
# (empty when not present). check_initialized uses _REMOTE_URL to fill in a
|
|
# copy-paste-ready `git clone` command for teammates whose store doesn't
|
|
# exist yet.
|
|
resolve_store() {
|
|
local resolved=""
|
|
local source=""
|
|
_REMOTE_URL=""
|
|
|
|
if [ -n "${STORE_OVERRIDE:-}" ]; then
|
|
resolved=$(_expand_store_path "$STORE_OVERRIDE")
|
|
source="--store flag"
|
|
_LAST_FOUND_AT=""
|
|
elif _find_result=$(_find_secrets_store_file); then
|
|
# _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
|
|
# IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
|
|
# save/restore needed. URL field is empty when .secrets-store didn't
|
|
# include a URL or when it was rejected as unsafe.
|
|
IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result"
|
|
source=".secrets-store file ($_LAST_FOUND_AT)"
|
|
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
|
|
resolved="$_USER_SECRETS_DIR"
|
|
source="SECRETS_DIR env var"
|
|
_LAST_FOUND_AT=""
|
|
else
|
|
resolved="$HOME/.secrets"
|
|
source="default"
|
|
_LAST_FOUND_AT=""
|
|
fi
|
|
|
|
SECRETS_DIR="$resolved"
|
|
KEY_FILE="$SECRETS_DIR/key.txt"
|
|
STORE_SOURCE="$source"
|
|
}
|
|
|
|
# Echo "==> Store: ..." when SECRETS_DIR is not the canonical default.
|
|
# Called from cmd_push/cmd_pull (and their workspace variants) after resolution.
|
|
echo_store_if_non_default() {
|
|
if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
|
|
info "Store: $SECRETS_DIR (from $STORE_SOURCE)"
|
|
fi
|
|
}
|
|
|
|
# ─── End store resolution ──────────────────────────────────────────────
|
|
|
|
# Collect secret files from a directory:
|
|
# .env, .env.*, .dev.vars (excluding .envrc, .environment-*)
|
|
# Sets the COLLECTED_FILES array. Returns 1 if no files found.
|
|
collect_env_files() {
|
|
local dir="$1"
|
|
COLLECTED_FILES=()
|
|
for f in "$dir"/.env "$dir"/.env.* "$dir"/.dev.vars; do
|
|
[ -f "$f" ] || continue
|
|
local basename_f
|
|
basename_f=$(basename "$f")
|
|
case "$basename_f" in
|
|
.envrc|.environment*) continue ;;
|
|
esac
|
|
COLLECTED_FILES+=("$f")
|
|
done
|
|
[ ${#COLLECTED_FILES[@]} -gt 0 ]
|
|
}
|
|
|
|
# ─── External files (.secrets-files) — EGB-531 ─────────────────────────
|
|
#
|
|
# A committed, project-local `.secrets-files` manifest declares files that
|
|
# live OUTSIDE the project root (e.g. global Gradle properties) whose
|
|
# *designated keys* should sync. One entry per non-empty non-comment line:
|
|
#
|
|
# gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
|
# <type> <path> <key> [key...]
|
|
#
|
|
# push extracts only the named keys → encrypts a subset blob under
|
|
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
|
|
# preserving every unrelated line. Only type `gradle-properties` is
|
|
# supported (the type token leaves room for future types without a plugin
|
|
# framework). Parsing mirrors `.secrets-store`: no shell expansion, no
|
|
# symlink following, conservative sanitization. The merge is pure bash
|
|
# (no sed/regex) with exact-string key matching, so it is safe against
|
|
# values containing `& \ /` and against substring keys
|
|
# (beaconClerkPk vs beaconClerkPkTest).
|
|
|
|
SECRETS_FILES_NAME=".secrets-files"
|
|
SUPPORTED_EXTERNAL_TYPE="gradle-properties"
|
|
|
|
# True if the string ends in an odd number of backslashes (a Java/Gradle
|
|
# properties line-continuation). Used to skip continuation lines when
|
|
# matching keys so an unrelated continued value is never clobbered.
|
|
_trailing_bs_odd() {
|
|
local s="$1" n=0
|
|
while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done
|
|
[ $((n % 2)) -eq 1 ]
|
|
}
|
|
|
|
# Parse .secrets-files. Emits "<type>\t<path>\t<keys>" per valid entry on
|
|
# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects
|
|
# shell/control chars and `..` in the path, restricts keys to a safe
|
|
# charset. No shell expansion is applied to file content.
|
|
_parse_secrets_files_manifest() {
|
|
local file="$1"
|
|
local line lineno=0 mtype mpath mkeys
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
lineno=$((lineno + 1))
|
|
line="${line%$'\r'}"
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
line="${line%"${line##*[![:space:]]}"}"
|
|
[ -z "$line" ] && continue
|
|
case "$line" in '#'*) continue ;; esac
|
|
# `read -r` does not glob-expand and keeps the key list intact in $mkeys.
|
|
read -r mtype mpath mkeys <<< "$line"
|
|
if [ "$mtype" != "$SUPPORTED_EXTERNAL_TYPE" ]; then
|
|
echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPE). Skipping." >&2
|
|
continue
|
|
fi
|
|
if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
|
|
echo "WARNING: $file line $lineno: expected '<type> <path> <key> [key...]'. Skipping." >&2
|
|
continue
|
|
fi
|
|
# Path: allow alnum and / . _ ~ - only; reject everything else (blocks
|
|
# \$ \` ; & | ( ) * ? whitespace etc.) and reject `..` traversal.
|
|
case "$mpath" in
|
|
*[!A-Za-z0-9/._~-]* | *..*)
|
|
echo "WARNING: $file line $lineno: unsafe characters in path. Paths may contain letters, digits, / . _ - ~ only. Skipping." >&2
|
|
continue
|
|
;;
|
|
esac
|
|
# Keys: space-separated; each may contain alnum and . _ - (covers the
|
|
# systemProp. prefix). Reject anything else (=, *, control chars, ...).
|
|
case "$mkeys" in
|
|
*[!A-Za-z0-9._\ -]*)
|
|
echo "WARNING: $file line $lineno: unsafe characters in key list. Skipping." >&2
|
|
continue
|
|
;;
|
|
esac
|
|
printf '%s\t%s\t%s\n' "$mtype" "$mpath" "$mkeys"
|
|
done < "$file"
|
|
}
|
|
|
|
# Validate a writable external target path. Refuses anything that isn't a
|
|
# `gradle.properties` resolving inside $HOME, blocks `..`, and refuses
|
|
# symlinks (target file or its parent dir) — the path comes from a
|
|
# committed file, so it is attacker-controllable. Returns 0 if safe.
|
|
_validate_external_target_path() {
|
|
local p="$1"
|
|
local base; base=$(basename "$p")
|
|
if [ "$base" != "gradle.properties" ]; then
|
|
echo "ERROR: $SECRETS_FILES_NAME: target basename must be 'gradle.properties' (got '$base'). Refusing." >&2
|
|
return 1
|
|
fi
|
|
case "$p" in *..*) echo "ERROR: $SECRETS_FILES_NAME: target path may not contain '..'. Refusing." >&2; return 1 ;; esac
|
|
local home_real; home_real=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_real="$HOME"
|
|
local dir; dir=$(dirname "$p")
|
|
# Resolve the deepest existing ancestor and require it inside $HOME.
|
|
local probe="$dir"
|
|
while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do
|
|
probe=$(dirname "$probe")
|
|
done
|
|
local probe_real
|
|
probe_real=$(cd -P "$probe" 2>/dev/null && pwd -P) || {
|
|
echo "ERROR: $SECRETS_FILES_NAME: cannot resolve target ancestor '$probe'. Refusing." >&2
|
|
return 1
|
|
}
|
|
case "$probe_real/" in
|
|
"$home_real"/*) ;;
|
|
*) echo "ERROR: $SECRETS_FILES_NAME: target '$p' must resolve inside \$HOME. Refusing." >&2; return 1 ;;
|
|
esac
|
|
if [ -L "$p" ]; then
|
|
echo "ERROR: $SECRETS_FILES_NAME: target '$p' is a symlink. Refusing to follow." >&2
|
|
return 1
|
|
fi
|
|
if [ -e "$dir" ] && [ -L "$dir" ]; then
|
|
echo "ERROR: $SECRETS_FILES_NAME: target dir '$dir' is a symlink. Refusing." >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Print the value of an exact key from a properties file (last definition
|
|
# wins, matching Gradle). Returns 1 if the key is absent. Pure bash; the
|
|
# key is compared by string equality (never as a regex), and the value is
|
|
# read verbatim. Skips comments and continuation lines.
|
|
_props_get() {
|
|
local file="$1" want="$2"
|
|
[ -f "$file" ] || return 1
|
|
local line t key after val="" found=0 cont=0
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
line="${line%$'\r'}"
|
|
if [ "$cont" -eq 1 ]; then
|
|
_trailing_bs_odd "$line" && cont=1 || cont=0
|
|
continue
|
|
fi
|
|
_trailing_bs_odd "$line" && cont=1 || cont=0
|
|
t="${line#"${line%%[![:space:]]*}"}"
|
|
case "$t" in ''|'#'*|'!'*) continue ;; esac
|
|
key="${t%%[=:[:space:]]*}"
|
|
[ "$key" = "$want" ] || continue
|
|
after="${t#"$key"}"
|
|
after="${after#"${after%%[![:space:]]*}"}"
|
|
case "$after" in [=:]*) after="${after#?}" ;; esac
|
|
after="${after#"${after%%[![:space:]]*}"}"
|
|
val="$after"
|
|
found=1
|
|
done < "$file"
|
|
[ "$found" -eq 1 ] || return 1
|
|
printf '%s' "$val"
|
|
}
|
|
|
|
# Turn a manifest path token into a machine-independent blob slug.
|
|
# A bare char-replace would collide (e.g. a/b and a_b both → a_b), so append a
|
|
# checksum of the original path to keep distinct targets' blobs distinct.
|
|
_secrets_files_slug() {
|
|
local p="$1"
|
|
local clean="${p//[!A-Za-z0-9._-]/_}"
|
|
local sum
|
|
sum=$(printf '%s' "$p" | cksum | cut -d' ' -f1)
|
|
printf '%s-%s' "$clean" "$sum"
|
|
}
|
|
|
|
# Merge managed key=value lines (from $2) into target file $1, preserving
|
|
# all unrelated lines/comments/order. Updates a managed key in place (first
|
|
# occurrence), collapses duplicates, appends new keys. Atomic + mode-safe.
|
|
merge_gradle_keys() {
|
|
local target="$1" kvfile="$2"
|
|
local -a mkey=() mval=() seen=()
|
|
local line key val i
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
[ -n "$line" ] || continue
|
|
key="${line%%=*}"
|
|
val="${line#*=}"
|
|
mkey+=("$key"); mval+=("$val"); seen+=(0)
|
|
done < "$kvfile"
|
|
local n=${#mkey[@]}
|
|
[ "$n" -gt 0 ] || return 0
|
|
|
|
local dir; dir=$(dirname "$target")
|
|
mkdir -p "$dir"
|
|
local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1
|
|
|
|
local cont=0 t tkey matched
|
|
if [ -f "$target" ]; then
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
local stripped="${line%$'\r'}"
|
|
if [ "$cont" -eq 1 ]; then
|
|
printf '%s\n' "$line" >> "$tmp"
|
|
_trailing_bs_odd "$stripped" && cont=1 || cont=0
|
|
continue
|
|
fi
|
|
_trailing_bs_odd "$stripped" && cont=1 || cont=0
|
|
t="${stripped#"${stripped%%[![:space:]]*}"}"
|
|
case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac
|
|
tkey="${t%%[=:[:space:]]*}"
|
|
matched=-1
|
|
for ((i = 0; i < n; i++)); do
|
|
if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi
|
|
done
|
|
if [ "$matched" -ge 0 ]; then
|
|
if [ "${seen[$matched]}" -eq 0 ]; then
|
|
printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp"
|
|
seen[$matched]=1
|
|
fi
|
|
else
|
|
printf '%s\n' "$line" >> "$tmp"
|
|
fi
|
|
done < "$target"
|
|
fi
|
|
for ((i = 0; i < n; i++)); do
|
|
if [ "${seen[$i]}" -eq 0 ]; then
|
|
printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp"
|
|
fi
|
|
done
|
|
|
|
# Back up the existing target before overwriting (seatbelt for the merge
|
|
# engine touching a hand-maintained file), preserve mode, atomic rename.
|
|
if [ -f "$target" ]; then
|
|
cp "$target" "$target.secrets-bak" 2>/dev/null || true
|
|
local mode
|
|
mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600)
|
|
chmod "$mode" "$tmp" 2>/dev/null || true
|
|
else
|
|
chmod 600 "$tmp" 2>/dev/null || true
|
|
fi
|
|
# TOCTOU recheck before the write into $HOME.
|
|
if [ -L "$target" ]; then
|
|
rm -f "$tmp"
|
|
echo "ERROR: $target became a symlink; aborting merge." >&2
|
|
return 1
|
|
fi
|
|
mv "$tmp" "$target"
|
|
}
|
|
|
|
# Encrypt the managed keys declared in <root>/.secrets-files into
|
|
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
|
|
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
|
|
push_external_files() {
|
|
local root="$1" project="$2" pubkey="$3"
|
|
local manifest="$root/$SECRETS_FILES_NAME"
|
|
[ -e "$manifest" ] || return 1
|
|
if [ -L "$manifest" ]; then
|
|
echo "WARNING: $manifest is a symlink; ignoring." >&2
|
|
return 1
|
|
fi
|
|
[ -f "$manifest" ] || return 1
|
|
|
|
local pushed=0 mtype mpath mkeys
|
|
while IFS=$'\t' read -r mtype mpath mkeys; do
|
|
[ -n "$mtype" ] || continue
|
|
local expanded; expanded=$(_expand_store_path "$mpath")
|
|
if ! _validate_external_target_path "$expanded"; then
|
|
die "Refusing unsafe external target in $SECRETS_FILES_NAME: $mpath"
|
|
fi
|
|
if [ ! -f "$expanded" ]; then
|
|
echo "WARNING: source '$mpath' not found on this machine; skipping." >&2
|
|
continue
|
|
fi
|
|
local tmp; tmp=$(mktemp)
|
|
local found=0 k v
|
|
for k in $mkeys; do
|
|
if v=$(_props_get "$expanded" "$k"); then
|
|
if _trailing_bs_odd "$v"; then
|
|
# A trailing odd backslash means a multi-line (continuation) value.
|
|
# We only support single-line values; syncing this would write a
|
|
# dangling backslash that turns the next target line into a
|
|
# continuation and corrupts the file. Skip it loudly.
|
|
echo "WARNING: key '$k' in $mpath has a multi-line (continuation) value — not supported, skipping." >&2
|
|
else
|
|
printf '%s=%s\n' "$k" "$v" >> "$tmp"
|
|
found=$((found + 1))
|
|
fi
|
|
else
|
|
echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2
|
|
fi
|
|
done
|
|
if [ "$found" -eq 0 ]; then
|
|
rm -f "$tmp"
|
|
die "No managed keys found in $mpath (looked for: $mkeys)."
|
|
fi
|
|
mkdir -p "$SECRETS_DIR/$project/external"
|
|
local slug; slug=$(_secrets_files_slug "$mpath")
|
|
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$mtype.age" "$tmp"
|
|
rm -f "$tmp"
|
|
info "Extracted $found key(s) from $mpath"
|
|
pushed=$((pushed + 1))
|
|
done < <(_parse_secrets_files_manifest "$manifest")
|
|
|
|
[ "$pushed" -gt 0 ]
|
|
}
|
|
|
|
# Decrypt the external blobs named by <root>/.secrets-files and merge their
|
|
# keys into the declared targets. Skips (with a warning) unsafe targets or
|
|
# missing blobs rather than aborting the whole pull.
|
|
pull_external_files() {
|
|
local root="$1" project="$2"
|
|
local manifest="$root/$SECRETS_FILES_NAME"
|
|
[ -e "$manifest" ] || return 0
|
|
if [ -L "$manifest" ]; then
|
|
echo "WARNING: $manifest is a symlink; ignoring." >&2
|
|
return 0
|
|
fi
|
|
[ -f "$manifest" ] || return 0
|
|
|
|
local mtype mpath mkeys
|
|
while IFS=$'\t' read -r mtype mpath mkeys; do
|
|
[ -n "$mtype" ] || continue
|
|
local expanded; expanded=$(_expand_store_path "$mpath")
|
|
if ! _validate_external_target_path "$expanded"; then
|
|
echo "WARNING: skipping unsafe external target: $mpath" >&2
|
|
continue
|
|
fi
|
|
local slug; slug=$(_secrets_files_slug "$mpath")
|
|
local blob="$SECRETS_DIR/$project/external/$slug.$mtype.age"
|
|
if [ ! -f "$blob" ]; then
|
|
echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2
|
|
continue
|
|
fi
|
|
local tmp; tmp=$(mktemp)
|
|
if ! age -d -i "$KEY_FILE" -o "$tmp" "$blob"; then
|
|
rm -f "$tmp"
|
|
die "Decryption failed for external target $mpath."
|
|
fi
|
|
local k k_esc
|
|
for k in $mkeys; do
|
|
# Escape regex-special '.' so e.g. systemProp.foo can't match systemPropXfoo.
|
|
k_esc=$(printf '%s' "$k" | sed 's/\./\\./g')
|
|
grep -q "^$k_esc=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2
|
|
done
|
|
if [ ! -d "$(dirname "$expanded")" ]; then
|
|
echo "WARNING: creating $(dirname "$expanded")" >&2
|
|
fi
|
|
local count; count=$(grep -c '=' "$tmp" 2>/dev/null || true); count=${count:-0}
|
|
if merge_gradle_keys "$expanded" "$tmp"; then
|
|
local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g')
|
|
info "Merged $count key(s) into $expanded ($klist)"
|
|
else
|
|
echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2
|
|
fi
|
|
rm -f "$tmp"
|
|
done < <(_parse_secrets_files_manifest "$manifest")
|
|
}
|
|
|
|
# Read package.json workspaces and expand globs to actual directories.
|
|
# Prints one workspace path per line (relative to the monorepo root).
|
|
get_workspaces() {
|
|
local root="$1"
|
|
local pkg="$root/package.json"
|
|
[ -f "$pkg" ] || die "No package.json found in $root"
|
|
check_cmd jq
|
|
|
|
local patterns
|
|
patterns=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null)
|
|
[ -n "$patterns" ] || die "No workspaces field in $pkg"
|
|
|
|
# Expand each glob pattern relative to root
|
|
local old_dir="$PWD"
|
|
cd "$root"
|
|
for pattern in $patterns; do
|
|
# Use bash glob expansion
|
|
for dir in $pattern; do
|
|
[ -d "$dir" ] && echo "$dir"
|
|
done
|
|
done
|
|
cd "$old_dir"
|
|
}
|
|
|
|
install_hook() {
|
|
local hook_src="$SCRIPT_DIR/hooks/pre-commit"
|
|
local hook_dst="$SECRETS_DIR/.git/hooks/pre-commit"
|
|
if [ -f "$hook_src" ]; then
|
|
cp "$hook_src" "$hook_dst"
|
|
chmod +x "$hook_dst"
|
|
else
|
|
# Inline hook if template not found (e.g. secrets installed standalone)
|
|
cat > "$hook_dst" << 'HOOKEOF'
|
|
#!/usr/bin/env bash
|
|
BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true)
|
|
if [ -n "$BLOCKED" ]; then
|
|
echo "ERROR: Plaintext secret files staged for commit:"
|
|
echo "$BLOCKED"
|
|
echo "Only .age (encrypted) files should be committed."
|
|
exit 1
|
|
fi
|
|
HOOKEOF
|
|
chmod +x "$hook_dst"
|
|
fi
|
|
}
|
|
|
|
# ─── Subcommands ───────────────────────────────────────────────────────
|
|
|
|
cmd_init() {
|
|
check_cmd age
|
|
check_cmd git
|
|
resolve_store
|
|
|
|
if [ -d "$SECRETS_DIR/.git" ]; then
|
|
die "Already initialized at $SECRETS_DIR. Key file preserved."
|
|
fi
|
|
|
|
info "Initializing secrets repo at $SECRETS_DIR"
|
|
mkdir -p "$SECRETS_DIR"
|
|
git init "$SECRETS_DIR" >/dev/null
|
|
|
|
# Generate age key pair
|
|
info "Generating age key pair"
|
|
age-keygen -o "$KEY_FILE" 2>&1
|
|
|
|
# Write .gitignore
|
|
cat > "$SECRETS_DIR/.gitignore" << 'EOF'
|
|
# Never commit the private key
|
|
key.txt
|
|
|
|
# Block plaintext secret files
|
|
**/.env
|
|
**/.env.*
|
|
**/.dev.vars
|
|
|
|
# Allow encrypted files
|
|
!**/.env.age
|
|
!**/.env.*.age
|
|
!**/.dev.vars.age
|
|
EOF
|
|
|
|
# Install pre-commit hook
|
|
mkdir -p "$SECRETS_DIR/.git/hooks"
|
|
install_hook
|
|
|
|
local pubkey
|
|
pubkey=$(get_pubkey)
|
|
|
|
info "Done! Your public key is:"
|
|
echo " $pubkey"
|
|
echo ""
|
|
echo "Next steps:"
|
|
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>"
|
|
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
|
|
echo " 3. Run 'secrets push <project>' from a project directory"
|
|
}
|
|
|
|
# Encrypt env files from a source dir into a project path in the secrets repo.
|
|
# Does NOT commit or push — caller handles that.
|
|
push_dir_to_project() {
|
|
local source_dir="$1"
|
|
local project="$2"
|
|
local pubkey="$3"
|
|
|
|
if ! collect_env_files "$source_dir"; then
|
|
return 1
|
|
fi
|
|
|
|
info "$project: ${#COLLECTED_FILES[@]} file(s)"
|
|
for f in "${COLLECTED_FILES[@]}"; do
|
|
echo " $(basename "$f")"
|
|
done
|
|
|
|
mkdir -p "$SECRETS_DIR/$project"
|
|
for f in "${COLLECTED_FILES[@]}"; do
|
|
local name
|
|
name=$(basename "$f")
|
|
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
|
done
|
|
return 0
|
|
}
|
|
|
|
# Git commit + push for the secrets repo. Shared by push and push --workspaces.
|
|
commit_and_push_secrets() {
|
|
local message="$1"
|
|
|
|
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then
|
|
die "Fast-forward pull failed. Run 'secrets pull' first, then retry push."
|
|
fi
|
|
fi
|
|
|
|
git -C "$SECRETS_DIR" add -A
|
|
if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
|
|
info "No changes to push (secrets unchanged)"
|
|
return
|
|
fi
|
|
git -C "$SECRETS_DIR" commit -m "$message" >/dev/null
|
|
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
|
info "Pushed to remote"
|
|
else
|
|
info "Committed locally (no remote configured)"
|
|
fi
|
|
}
|
|
|
|
cmd_push() {
|
|
check_cmd age
|
|
check_cmd git
|
|
resolve_store
|
|
check_initialized
|
|
check_key
|
|
|
|
local project
|
|
project=$(derive_project_name "${1:-}")
|
|
info "Pushing secrets for project: $project"
|
|
echo_store_if_non_default
|
|
|
|
local pubkey
|
|
pubkey=$(get_pubkey)
|
|
|
|
local did=0
|
|
if push_dir_to_project "$PWD" "$project" "$pubkey"; then did=1; fi
|
|
if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
|
|
if [ "$did" -eq 0 ]; then
|
|
die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
|
|
fi
|
|
|
|
commit_and_push_secrets "update $project"
|
|
}
|
|
|
|
cmd_push_workspaces() {
|
|
check_cmd age
|
|
check_cmd git
|
|
check_cmd jq
|
|
resolve_store
|
|
check_initialized
|
|
check_key
|
|
|
|
local root="$PWD"
|
|
local monorepo_name
|
|
monorepo_name=$(derive_project_name "")
|
|
info "Pushing workspaces for monorepo: $monorepo_name"
|
|
echo_store_if_non_default
|
|
|
|
local pubkey
|
|
pubkey=$(get_pubkey)
|
|
local total=0
|
|
|
|
# Push root env files (if any)
|
|
if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then
|
|
total=$((total + ${#COLLECTED_FILES[@]}))
|
|
fi
|
|
|
|
# Push each workspace
|
|
local workspaces
|
|
workspaces=$(get_workspaces "$root")
|
|
while IFS= read -r ws; do
|
|
[ -n "$ws" ] || continue
|
|
local ws_dir="$root/$ws"
|
|
local ws_project="$monorepo_name/$ws"
|
|
if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then
|
|
total=$((total + ${#COLLECTED_FILES[@]}))
|
|
fi
|
|
done <<< "$workspaces"
|
|
|
|
# External files (.secrets-files) are monorepo-root-scoped, like
|
|
# .secrets-store — handle once, not per-workspace.
|
|
if push_external_files "$root" "$monorepo_name" "$pubkey"; then
|
|
total=$((total + 1))
|
|
fi
|
|
|
|
if [ "$total" -eq 0 ]; then
|
|
die "No secret files found in any workspace"
|
|
fi
|
|
|
|
commit_and_push_secrets "update $monorepo_name workspaces"
|
|
}
|
|
|
|
cmd_pull() {
|
|
check_cmd age
|
|
check_cmd git
|
|
resolve_store
|
|
check_initialized
|
|
check_key
|
|
|
|
local project
|
|
project=$(derive_project_name "${1:-}")
|
|
local target_dir="$PWD"
|
|
info "Pulling secrets for project: $project"
|
|
echo_store_if_non_default
|
|
|
|
# Pull latest
|
|
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
|
|
fi
|
|
|
|
# Check project exists
|
|
if [ ! -d "$SECRETS_DIR/$project" ]; then
|
|
die "Project '$project' not found. Run: secrets list"
|
|
fi
|
|
|
|
# Decrypt each .age file into target dir (including dotfiles)
|
|
local count=0
|
|
for f in "$SECRETS_DIR/$project"/*.age "$SECRETS_DIR/$project"/.*.age; do
|
|
[ -f "$f" ] || continue
|
|
local name
|
|
name=$(basename "$f" .age)
|
|
local outfile="$target_dir/$name"
|
|
age -d -i "$KEY_FILE" -o "$outfile" "$f"
|
|
# Integrity check: verify non-empty
|
|
if [ ! -s "$outfile" ]; then
|
|
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)"
|
|
fi
|
|
count=$((count + 1))
|
|
done
|
|
|
|
info "Decrypted $count file(s) into $target_dir"
|
|
|
|
# Merge any external files (.secrets-files) declared in this project.
|
|
pull_external_files "$PWD" "$project"
|
|
|
|
# Reinstall hook if missing
|
|
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
|
|
install_hook
|
|
info "Reinstalled pre-commit hook"
|
|
fi
|
|
}
|
|
|
|
# Pull and decrypt .age files from a project path into a target directory.
|
|
# Does NOT do git pull — caller handles that.
|
|
pull_project_to_dir() {
|
|
local project="$1"
|
|
local target_dir="$2"
|
|
local project_dir="$SECRETS_DIR/$project"
|
|
|
|
[ -d "$project_dir" ] || return 1
|
|
|
|
local count=0
|
|
for f in "$project_dir"/*.age "$project_dir"/.*.age; do
|
|
[ -f "$f" ] || continue
|
|
local name
|
|
name=$(basename "$f" .age)
|
|
local outfile="$target_dir/$name"
|
|
age -d -i "$KEY_FILE" -o "$outfile" "$f"
|
|
if [ ! -s "$outfile" ]; then
|
|
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)"
|
|
fi
|
|
count=$((count + 1))
|
|
done
|
|
echo "$count"
|
|
}
|
|
|
|
cmd_pull_workspaces() {
|
|
check_cmd age
|
|
check_cmd git
|
|
check_cmd jq
|
|
resolve_store
|
|
check_initialized
|
|
check_key
|
|
|
|
local root="$PWD"
|
|
local monorepo_name
|
|
monorepo_name=$(derive_project_name "")
|
|
info "Pulling workspaces for monorepo: $monorepo_name"
|
|
echo_store_if_non_default
|
|
|
|
# Pull latest from remote
|
|
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
|
|
fi
|
|
|
|
local total=0
|
|
|
|
# Pull root secrets (if any)
|
|
if [ -d "$SECRETS_DIR/$monorepo_name" ]; then
|
|
local count
|
|
count=$(pull_project_to_dir "$monorepo_name" "$root")
|
|
total=$((total + count))
|
|
info "$monorepo_name (root): $count file(s)"
|
|
fi
|
|
|
|
# Pull each workspace
|
|
local workspaces
|
|
workspaces=$(get_workspaces "$root")
|
|
while IFS= read -r ws; do
|
|
[ -n "$ws" ] || continue
|
|
local ws_dir="$root/$ws"
|
|
local ws_project="$monorepo_name/$ws"
|
|
if [ -d "$SECRETS_DIR/$ws_project" ]; then
|
|
local count
|
|
count=$(pull_project_to_dir "$ws_project" "$ws_dir")
|
|
total=$((total + count))
|
|
info "$ws_project: $count file(s)"
|
|
fi
|
|
done <<< "$workspaces"
|
|
|
|
# External files (.secrets-files) are monorepo-root-scoped — once.
|
|
if [ -f "$root/$SECRETS_FILES_NAME" ] && [ ! -L "$root/$SECRETS_FILES_NAME" ]; then
|
|
pull_external_files "$root" "$monorepo_name"
|
|
total=$((total + 1))
|
|
fi
|
|
|
|
if [ "$total" -eq 0 ]; then
|
|
die "No secrets found for any workspace in $monorepo_name"
|
|
fi
|
|
|
|
info "Decrypted $total file(s) total"
|
|
|
|
# Reinstall hook if missing
|
|
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
|
|
install_hook
|
|
info "Reinstalled pre-commit hook"
|
|
fi
|
|
}
|
|
|
|
cmd_list() {
|
|
resolve_store
|
|
check_initialized
|
|
|
|
local found=0
|
|
for dir in "$SECRETS_DIR"/*/; do
|
|
[ -d "$dir" ] || continue
|
|
local project
|
|
project=$(basename "$dir")
|
|
# Skip hidden dirs
|
|
[[ "$project" == .* ]] && continue
|
|
echo "$project:"
|
|
for f in "$dir"*.age "$dir".*.age; do
|
|
[ -f "$f" ] || continue
|
|
echo " $(basename "$f" .age)"
|
|
found=1
|
|
done
|
|
# External files live in a subdir, invisible to the globs above.
|
|
if [ -d "${dir}external" ]; then
|
|
for f in "${dir}external"/*.age; do
|
|
[ -f "$f" ] || continue
|
|
echo " [external] $(basename "$f" .age)"
|
|
found=1
|
|
done
|
|
fi
|
|
done
|
|
|
|
if [ "$found" -eq 0 ]; then
|
|
echo "No projects found. Run 'secrets push <project>' to add one."
|
|
fi
|
|
|
|
# DX-7 hint: when a non-default store is active, point users at `secrets which`.
|
|
if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
|
|
echo ""
|
|
info "Showing projects in $SECRETS_DIR. Run 'secrets which' for details."
|
|
fi
|
|
}
|
|
|
|
cmd_rm() {
|
|
check_cmd git
|
|
resolve_store
|
|
check_initialized
|
|
|
|
local project="${1:-}"
|
|
[ -n "$project" ] || die "Usage: secrets rm <project>"
|
|
|
|
if [ ! -d "$SECRETS_DIR/$project" ]; then
|
|
die "Project '$project' not found. Run: secrets list"
|
|
fi
|
|
|
|
info "Removing project: $project"
|
|
git -C "$SECRETS_DIR" rm -r "$project/" >/dev/null
|
|
git -C "$SECRETS_DIR" commit -m "remove $project" >/dev/null
|
|
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
|
info "Removed $project from remote"
|
|
else
|
|
info "Removed $project locally (no remote configured)"
|
|
fi
|
|
}
|
|
|
|
cmd_rekey() {
|
|
check_cmd age
|
|
check_cmd git
|
|
resolve_store
|
|
check_initialized
|
|
check_key
|
|
|
|
# Create temp dir with cleanup trap
|
|
local tmpdir
|
|
tmpdir=$(mktemp -d)
|
|
# `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the
|
|
# function returns (the local would be out of scope). Normal completion
|
|
# cleans up explicitly and clears the trap at the end of this function.
|
|
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
|
|
|
|
info "Decrypting all files with current key..."
|
|
|
|
# Decrypt all .age files into temp dir
|
|
local file_count=0
|
|
for dir in "$SECRETS_DIR"/*/; do
|
|
[ -d "$dir" ] || continue
|
|
local project
|
|
project=$(basename "$dir")
|
|
[[ "$project" == .* ]] && continue
|
|
mkdir -p "$tmpdir/$project"
|
|
for f in "$dir"*.age "$dir".*.age; do
|
|
[ -f "$f" ] || continue
|
|
local name
|
|
name=$(basename "$f" .age)
|
|
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/$name" "$f"; then
|
|
die "Decryption failed for $project/$name. Rekey aborted. Old key preserved."
|
|
fi
|
|
file_count=$((file_count + 1))
|
|
done
|
|
# External files live in a subdir; rekey them too or they become
|
|
# undecryptable after rotation.
|
|
if [ -d "${dir}external" ]; then
|
|
mkdir -p "$tmpdir/$project/external"
|
|
for f in "${dir}external"/*.age; do
|
|
[ -f "$f" ] || continue
|
|
local ename
|
|
ename=$(basename "$f" .age)
|
|
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/external/$ename" "$f"; then
|
|
die "Decryption failed for $project/external/$ename. Rekey aborted. Old key preserved."
|
|
fi
|
|
file_count=$((file_count + 1))
|
|
done
|
|
fi
|
|
done
|
|
|
|
if [ "$file_count" -eq 0 ]; then
|
|
die "No encrypted files found. Nothing to rekey."
|
|
fi
|
|
|
|
info "Decrypted $file_count file(s). Generating new key pair..."
|
|
|
|
# Generate the new key. age-keygen refuses to overwrite an existing
|
|
# file, so generate into the temp dir and move it into place only on
|
|
# success — the old key stays intact if generation fails.
|
|
local newkey="$tmpdir/key.txt.new"
|
|
age-keygen -o "$newkey" 2>&1
|
|
mv "$newkey" "$KEY_FILE"
|
|
local pubkey
|
|
pubkey=$(get_pubkey)
|
|
|
|
info "Re-encrypting all files with new key..."
|
|
|
|
# Re-encrypt all files
|
|
for dir in "$tmpdir"/*/; do
|
|
[ -d "$dir" ] || continue
|
|
local project
|
|
project=$(basename "$dir")
|
|
mkdir -p "$SECRETS_DIR/$project"
|
|
for f in "$dir"*; do
|
|
[ -f "$f" ] || continue
|
|
local name
|
|
name=$(basename "$f")
|
|
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
|
done
|
|
if [ -d "${dir}external" ]; then
|
|
mkdir -p "$SECRETS_DIR/$project/external"
|
|
for f in "${dir}external"/*; do
|
|
[ -f "$f" ] || continue
|
|
local ename
|
|
ename=$(basename "$f")
|
|
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/${ename}.age" "$f"
|
|
done
|
|
fi
|
|
done
|
|
|
|
# Commit and push
|
|
git -C "$SECRETS_DIR" add -A
|
|
git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null
|
|
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
|
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
|
info "Pushed rekeyed secrets to remote"
|
|
else
|
|
info "Committed rekeyed secrets locally (no remote configured)"
|
|
fi
|
|
|
|
info "Rekey complete!"
|
|
echo ""
|
|
echo "IMPORTANT: Copy new key to your other machine:"
|
|
echo " scp $KEY_FILE <other-machine>:$KEY_FILE"
|
|
echo ""
|
|
echo "WARNING: Old ciphertext remains in git history."
|
|
echo "For full rotation, create a fresh repo."
|
|
|
|
# Clean up the plaintext temp dir while $tmpdir is still in scope, then
|
|
# drop the trap so it can't fire (and error under set -u) post-return.
|
|
rm -rf "$tmpdir"
|
|
trap - EXIT INT TERM
|
|
}
|
|
|
|
cmd_clear() {
|
|
local dir="$PWD"
|
|
if ! collect_env_files "$dir"; then
|
|
info "No secret files to clear in $dir"
|
|
return
|
|
fi
|
|
|
|
local count=0
|
|
for f in "${COLLECTED_FILES[@]}"; do
|
|
rm "$f"
|
|
count=$((count + 1))
|
|
done
|
|
info "Cleared $count secret file(s) from $dir"
|
|
}
|
|
|
|
cmd_clear_workspaces() {
|
|
check_cmd jq
|
|
|
|
local root="$PWD"
|
|
local total=0
|
|
|
|
# Clear root
|
|
if collect_env_files "$root"; then
|
|
for f in "${COLLECTED_FILES[@]}"; do
|
|
rm "$f"
|
|
total=$((total + 1))
|
|
done
|
|
fi
|
|
|
|
# Clear each workspace
|
|
local workspaces
|
|
workspaces=$(get_workspaces "$root")
|
|
while IFS= read -r ws; do
|
|
[ -n "$ws" ] || continue
|
|
local ws_dir="$root/$ws"
|
|
if collect_env_files "$ws_dir"; then
|
|
for f in "${COLLECTED_FILES[@]}"; do
|
|
rm "$f"
|
|
total=$((total + 1))
|
|
done
|
|
fi
|
|
done <<< "$workspaces"
|
|
|
|
info "Cleared $total secret file(s) from workspace"
|
|
}
|
|
|
|
# EXIT-trap helpers for cmd_run. Defined as functions (not inline string
|
|
# traps) so $_RUN_PWD is dereferenced safely regardless of special chars
|
|
# in the path. F1: trap "cd '$X'" breaks when X contains a single quote.
|
|
_run_cleanup() {
|
|
cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear
|
|
}
|
|
_run_cleanup_workspaces() {
|
|
cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear_workspaces
|
|
}
|
|
|
|
cmd_run() {
|
|
local workspace_mode=false
|
|
local project=""
|
|
|
|
# Parse flags before the command
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-w|--workspaces) workspace_mode=true; shift ;;
|
|
--) shift; break ;;
|
|
-*) die "Unknown flag: $1. Usage: secrets run [-w] [--] <command...>" ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
|
|
[ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"
|
|
|
|
# F8: pin the project directory now so the EXIT trap clears the right
|
|
# plaintext files even if the user's command does `cd` into another dir.
|
|
# Use a global + named function (NOT string-interpolated trap) so paths
|
|
# with special characters — apostrophes, dollar signs, spaces — work.
|
|
# Single-quoting `$_RUN_PWD` into a string trap would break on any path
|
|
# with a single quote (e.g. /Users/bri/it's-app), and the trap would
|
|
# silently fail to clean up plaintext secrets. EGB-281 F1.
|
|
_RUN_PWD="$PWD"
|
|
|
|
# Pull secrets (cmd_pull/cmd_pull_workspaces call resolve_store internally)
|
|
if [ "$workspace_mode" = true ]; then
|
|
cmd_pull_workspaces
|
|
else
|
|
cmd_pull "$project"
|
|
fi
|
|
|
|
if [ "$workspace_mode" = true ]; then
|
|
trap _run_cleanup_workspaces EXIT
|
|
else
|
|
trap _run_cleanup EXIT
|
|
fi
|
|
|
|
# Execute the command, capturing exit code (don't let set -e kill us)
|
|
local rc=0
|
|
"$@" || rc=$?
|
|
exit "$rc"
|
|
}
|
|
|
|
cmd_which() {
|
|
resolve_store
|
|
echo "store: $SECRETS_DIR"
|
|
echo "source: $STORE_SOURCE"
|
|
|
|
# Read back any external-file manifest in cwd (validates the format and
|
|
# gives the user a way to confirm it parsed, since there's no add-file
|
|
# command). Skips symlinked manifests.
|
|
local manifest="$PWD/$SECRETS_FILES_NAME"
|
|
if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then
|
|
local mtype mpath mkeys printed=0
|
|
while IFS=$'\t' read -r mtype mpath mkeys; do
|
|
[ -n "$mtype" ] || continue
|
|
if [ "$printed" -eq 0 ]; then
|
|
echo "external files ($SECRETS_FILES_NAME at $manifest):"
|
|
printed=1
|
|
fi
|
|
echo " $mtype $mpath $mkeys"
|
|
done < <(_parse_secrets_files_manifest "$manifest")
|
|
fi
|
|
}
|
|
|
|
cmd_help() {
|
|
cat << 'EOF'
|
|
secrets — encrypted secret file sync between machines
|
|
|
|
Usage:
|
|
secrets init Initialize the secrets repo and generate an age key
|
|
secrets push [project] Encrypt secret files and push to the secrets repo
|
|
secrets push -w|--workspaces Push secrets from all workspaces in package.json
|
|
secrets pull [project] Pull and decrypt secret files into current directory
|
|
secrets pull -w|--workspaces Pull secrets into all workspaces from package.json
|
|
secrets clear Remove plaintext secret files from current directory
|
|
secrets clear -w|--workspaces Clear secrets from all workspaces in package.json
|
|
secrets run [-w] <command> Pull secrets, run command, clear secrets on exit
|
|
secrets list List all projects and their secret files
|
|
secrets rm <project> Remove a project's secrets from the repo
|
|
secrets rekey Re-encrypt all secrets with a new key
|
|
secrets which Show the active store path and which rule chose it
|
|
secrets where Alias for `which`
|
|
secrets status Alias for `which`
|
|
|
|
Tracked files: .env, .env.*, .dev.vars
|
|
|
|
If [project] is omitted, it is derived from the current directory's
|
|
git remote (if available) or the directory name.
|
|
|
|
Stores:
|
|
Most users have one ~/.secrets/ store. To use a separate store (e.g.
|
|
for work secrets vs personal), use any of these resolution rules
|
|
(highest precedence first):
|
|
|
|
1. --store <dir> flag secrets --store ~/.secrets-work pull
|
|
2. .secrets-store file in project echo work > .secrets-store && git add ...
|
|
3. SECRETS_DIR env var (legacy) SECRETS_DIR=~/.secrets-work secrets pull
|
|
4. ~/.secrets default
|
|
|
|
Bare names ("work") expand to ~/.secrets-work. The name "default"
|
|
resolves to ~/.secrets. Run `secrets which` to inspect the active store.
|
|
|
|
Optional .secrets-store URL hint:
|
|
A second whitespace-separated token on the line is treated as the
|
|
store's git remote URL. It is used to fill in a runnable `git clone
|
|
<url> <path>` in the missing-store error so teammates joining the
|
|
project don't have to ask for the URL. Example:
|
|
work git@github.com:acme/work-secrets.git
|
|
|
|
Workspaces:
|
|
With -w/--workspaces, reads package.json "workspaces" field to find
|
|
workspace directories. Each workspace's secret files are stored under
|
|
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
|
|
are stored under <monorepo>/ directly. Requires jq.
|
|
|
|
External files (.secrets-files):
|
|
Sync specific keys from files OUTSIDE the project root (e.g. global
|
|
Gradle properties). Create a committed .secrets-files in the project
|
|
root, one entry per line:
|
|
|
|
# <type> <path> <keys...>
|
|
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
|
|
|
|
On push, the named keys are extracted from <path> and encrypted under
|
|
<project>/external/ in the store. On pull, they are MERGED back into
|
|
<path>, leaving all unrelated keys untouched. Only type
|
|
'gradle-properties' is supported; the target basename must be
|
|
'gradle.properties' and must resolve inside $HOME. Run 'secrets which'
|
|
from the project to confirm the manifest parsed.
|
|
|
|
Note: merged keys are written as permanent plaintext into the target
|
|
file (suitable for publishable/low-secrecy values). 'secrets clear'
|
|
does NOT remove them.
|
|
|
|
Environment:
|
|
SECRETS_DIR Path to secrets repo (default: ~/.secrets). See also
|
|
the .secrets-store file and --store flag above.
|
|
EOF
|
|
}
|
|
|
|
# ─── Main ──────────────────────────────────────────────────────────────
|
|
|
|
# Pre-pass: extract --store flag from anywhere in the args (before `--` only).
|
|
# Lets `secrets --store work push`, `secrets push --store work`, and
|
|
# `secrets run --store work cmd ...` all work consistently. After `--`,
|
|
# args belong to the user's command and are passed through untouched.
|
|
ARGS=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--)
|
|
# Stop pre-pass; pass `--` and everything after through untouched
|
|
# so the user's command (e.g. `secrets run -- cmd --store foo`) is
|
|
# not mangled.
|
|
ARGS+=("$@")
|
|
break
|
|
;;
|
|
--store)
|
|
[ $# -ge 2 ] || die "--store requires a directory or store name"
|
|
# F3: reject values that look like another flag — almost always a typo
|
|
# (`secrets --store push` → user dropped the value, would silently use
|
|
# ~/.secrets-push and surface a confusing "not initialized" error).
|
|
case "$2" in
|
|
--|-*) die "--store value looks like a flag: $2 (did you forget the value?)" ;;
|
|
esac
|
|
STORE_OVERRIDE="$2"
|
|
shift 2
|
|
;;
|
|
--store=*)
|
|
# F4: --store= with empty value used to silently fall through to the
|
|
# next rule. Treat it as a typo too.
|
|
[ -n "${1#--store=}" ] || die "--store= requires a value"
|
|
STORE_OVERRIDE="${1#--store=}"
|
|
shift
|
|
;;
|
|
*)
|
|
ARGS+=("$1")
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
# Re-set positional params. Bulletproof against empty array under `set -u`.
|
|
if [ ${#ARGS[@]} -gt 0 ]; then
|
|
set -- "${ARGS[@]}"
|
|
else
|
|
set --
|
|
fi
|
|
|
|
case "${1:-help}" in
|
|
init) cmd_init ;;
|
|
push)
|
|
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
|
|
cmd_push_workspaces
|
|
else
|
|
cmd_push "${2:-}"
|
|
fi
|
|
;;
|
|
pull)
|
|
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
|
|
cmd_pull_workspaces
|
|
else
|
|
cmd_pull "${2:-}"
|
|
fi
|
|
;;
|
|
clear)
|
|
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
|
|
cmd_clear_workspaces
|
|
else
|
|
cmd_clear
|
|
fi
|
|
;;
|
|
run)
|
|
shift
|
|
cmd_run "$@"
|
|
;;
|
|
list) cmd_list ;;
|
|
rm) cmd_rm "${2:-}" ;;
|
|
rekey) cmd_rekey ;;
|
|
which|where|status) cmd_which ;;
|
|
help|--help|-h) cmd_help ;;
|
|
*) die "Unknown command: $1. Run 'secrets help' for usage." ;;
|
|
esac
|