#!/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 ` # 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="" 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 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 ` 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 "\t" # (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 `` 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 "\t\t" # (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 "\t" (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 ()" | "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 "\t\t" # 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 # [key...] # # push extracts only the named keys → encrypts a subset blob under # /external/. pull decrypts it and MERGES those keys into , # 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 "\t\t" 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 ' [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 /.secrets-files into # /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 /.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 " echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" echo " 3. Run 'secrets push ' 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 ' 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 " 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 :$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] [--] " ;; *) break ;; esac done [ $# -gt 0 ] || die "Usage: secrets run [-w] [--] " # 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] Pull secrets, run command, clear secrets on exit secrets list List all projects and their secret files secrets rm 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 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 ` 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 // in the secrets repo. Root secret files are stored under / 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: # gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive On push, the named keys are extracted from and encrypted under /external/ in the store. On pull, they are MERGED back into , 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