#!/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}" RECIPIENTS_FILE_NAME="recipients.txt" KEY_FILE="$SECRETS_DIR/key.txt" RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME" 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 && return # Platform-aware install hint — hardcoding brew is wrong guidance on a # Linux box or CI runner (EGB-677 DX review). local hint="install '$1' with your package manager" if command -v brew >/dev/null 2>&1; then hint="brew install $1" elif command -v apt-get >/dev/null 2>&1; then hint="sudo apt-get install $1" elif command -v dnf >/dev/null 2>&1; then hint="sudo dnf install $1" fi die "'$1' is not installed. Run: $hint" } check_initialized() { if [ -d "$SECRETS_DIR/.git" ]; then _check_store_version_skew 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" } # A native age X25519 recipient: "age1" + exactly 58 chars of [0-9a-z]. # This is also the injection rail — it cannot hold shell metacharacters, # whitespace, control chars, or extra flags. SSH recipients are intentionally # unsupported (EGB-283 scope cut). _validate_age_recipient() { case "$1" in age1*) : ;; *) return 1 ;; esac local body="${1#age1}" [ "${#body}" -eq 58 ] || return 1 case "$body" in *[!0-9a-z]*) return 1 ;; esac return 0 } # Recipient display names become "# " comment lines in recipients.txt. # Restrict to a safe charset so a name can't inject extra lines/metacharacters. _validate_recipient_name() { case "$1" in *[!A-Za-z0-9\ ._-]*) return 1 ;; *) return 0 ;; esac } # Populate the global RECIPIENT_ARGS array with one "-r " per store # recipient. recipients.txt present -> validated keys from the file (the store # is multi-recipient). Absent -> the single pubkey derived from key.txt (legacy # single-key store, exactly today's behavior). We parse the file ourselves # (never `age -R `) because it is committed = an injection surface; every # line is validated and the file is refused if symlinked. Dies on any problem. RECIPIENT_ARGS=() _load_recipients() { RECIPIENT_ARGS=() if [ -L "$RECIPIENTS_FILE" ]; then die "Refusing to read symlinked $RECIPIENTS_FILE_NAME (security)." fi if [ ! -e "$RECIPIENTS_FILE" ]; then RECIPIENT_ARGS=(-r "$(get_pubkey)") return 0 fi local line trimmed n=0 while IFS= read -r line || [ -n "$line" ]; do trimmed="${line#"${line%%[![:space:]]*}"}" # lstrip trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rstrip [ -z "$trimmed" ] && continue case "$trimmed" in '#'*) continue ;; esac if ! _validate_age_recipient "$trimmed"; then die "Invalid recipient in $RECIPIENTS_FILE_NAME: '$trimmed' (expected a native age key: age1...)." fi RECIPIENT_ARGS+=(-r "$trimmed") n=$((n + 1)) done < "$RECIPIENTS_FILE" if [ "$n" -eq 0 ]; then die "$RECIPIENTS_FILE_NAME has no recipients — a store must have at least one. Run 'secrets recipients add '." fi } # Emit "\t" for each recipient in recipients.txt. is the most # recent preceding "# " comment, or empty. Read-only; no validation # (callers that need rails call _load_recipients separately). _recipients_dump() { [ -e "$RECIPIENTS_FILE" ] || return 0 local line trimmed name="" while IFS= read -r line || [ -n "$line" ]; do trimmed="${line#"${line%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" [ -z "$trimmed" ] && continue case "$trimmed" in '#'*) name="${trimmed#\#}" name="${name#"${name%%[![:space:]]*}"}" ;; *) printf '%s\t%s\n' "$trimmed" "$name" name="" ;; esac done < "$RECIPIENTS_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" RECIPIENTS_FILE="$SECRETS_DIR/$RECIPIENTS_FILE_NAME" 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. 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). # # A second type, `file` (EGB-652), syncs a WHOLE file outside the project # root (e.g. an Android upload keystore) — binary-safe via age, no keys: # # file ~/keystores/beacon-upload.keystore # # push encrypts the file verbatim; pull restores it (mode 600, existing # target backed up to .secrets-bak first). Same path rules as # gradle-properties (inside $HOME, no `..`, no symlinks) minus the # basename restriction. SECRETS_FILES_NAME=".secrets-files" SUPPORTED_EXTERNAL_TYPES="gradle-properties file" # 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" case "$mtype" in gradle-properties) if [ -z "$mpath" ] || [ -z "$mkeys" ]; then echo "WARNING: $file line $lineno: expected 'gradle-properties [key...]'. Skipping." >&2 continue fi ;; file) if [ -z "$mpath" ]; then echo "WARNING: $file line $lineno: expected 'file '. Skipping." >&2 continue fi if [ -n "$mkeys" ]; then echo "WARNING: $file line $lineno: 'file' entries take no keys (got '$mkeys'). Skipping." >&2 continue fi ;; *) echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPES). Skipping." >&2 continue ;; esac # 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. Requires the path to resolve # inside $HOME, blocks `..`, and refuses symlinks (target file or its # parent dir) — the path comes from a committed file, so it is # attacker-controllable. For type `gradle-properties` the basename must be # 'gradle.properties' (type `file` allows any basename — the whole file is # replaced, never merged). Returns 0 if safe. _validate_external_target_path() { local p="$1" mtype="${2:-gradle-properties}" local base; base=$(basename "$p") if [ "$mtype" = "gradle-properties" ]; then # EGB-677: generalized from exact 'gradle.properties' to any # '*.properties' basename — still blocks merging key=value lines # into ~/.bashrc / ~/.gitconfig style targets. case "$base" in *.properties) ;; *) echo "ERROR: properties target basename must end in '.properties' (got '$base'). Refusing." >&2 return 1 ;; esac 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" } # ─── Store format (EGB-703) ─────────────────────────────────────────── # # The store self-describes its format via a committed one-line file # `$SECRETS_DIR/.secrets-format` containing `2`. Absence (or any non-`2` # content) means format v1 — the legacy default for every store that # predates EGB-703. `init` stamps a fresh store v2 (born-v2); `migrate # --finalize` stamps a migrated store v2. Requires resolve_store to have # run (SECRETS_DIR set). STORE_FORMAT_FILE_NAME=".secrets-format" _store_format() { local f="$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" v if [ -f "$f" ]; then # Strict exact match (modulo line endings): only a marker of exactly "2" # reads as v2. Anything else (empty, "20", "v2", garbage) ⇒ v1 — the safe # default, since misreading v2-as-v1 only triggers a harmless re-migrate # while v1-as-v2 would mislocate blobs. v=$(head -1 "$f" 2>/dev/null | tr -d '\r\n') [ "$v" = "2" ] && { echo 2; return; } fi echo 1 } # ─── Client/store version skew (EGB-713) ────────────────────────────── # # The running client's own version, read from the VERSION file shipped beside # the script. Empty/"0.0.0.0" if absent — treated as "unknown/oldest" so a # missing VERSION never triggers a spurious nudge. _client_version() { local v="" [ -f "$SCRIPT_DIR/VERSION" ] && v=$(head -1 "$SCRIPT_DIR/VERSION" 2>/dev/null | tr -d '\r\n[:space:]') printf '%s' "${v:-0.0.0.0}" } # Numeric four-field (MAJOR.MINOR.PATCH.MICRO) compare. Returns 0 iff $1 > $2. # Per-field numeric (so 0.10.0.0 > 0.7.0.0); missing/garbage fields → 0. _version_gt() { local a="$1" b="$2" i ai bi; local -a af bf IFS=. read -r -a af <<< "$a"; IFS=. read -r -a bf <<< "$b" for i in 0 1 2 3; do ai=${af[$i]:-0}; ai=${ai//[!0-9]/}; [ -n "$ai" ] || ai=0 bi=${bf[$i]:-0}; bi=${bi//[!0-9]/}; [ -n "$bi" ] || bi=0 if [ "$((10#$ai))" -gt "$((10#$bi))" ]; then return 0; fi if [ "$((10#$ai))" -lt "$((10#$bi))" ]; then return 1; fi done return 1 } WRITER_VERSION_FILE_NAME=".secrets-writer-version" # Highest client version recorded as having written to the store (empty if the # store predates this feature — "minus the initial builds", silent by design). _store_writer_version() { local f="$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" if [ -f "$f" ]; then head -1 "$f" 2>/dev/null | tr -d '\r\n[:space:]' fi return 0 } # Raise the store's recorded writer-version to the client's version (monotonic; # never lowers it). Called right before each store-committing `git add -A` so # the stamp rides the same commit. Read paths (pull) never call this. _stamp_writer_version() { local cur cli cur=$(_store_writer_version) cli=$(_client_version) if [ -z "$cur" ] || _version_gt "$cli" "$cur"; then printf '%s\n' "$cli" > "$SECRETS_DIR/$WRITER_VERSION_FILE_NAME" fi } # Warn ONCE per invocation if the store was last written by a newer client than # us. Non-fatal (read/write paths keep their exit codes). Silent when the store # carries no writer-version (legacy) or is same/older than us. _VERSION_SKEW_WARNED=0 _check_store_version_skew() { [ "$_VERSION_SKEW_WARNED" = 1 ] && return 0 local sv cv sv=$(_store_writer_version) [ -n "$sv" ] || return 0 cv=$(_client_version) if _version_gt "$sv" "$cv"; then _VERSION_SKEW_WARNED=1 echo "NOTE: this store was last written by secrets v$sv; you're on v$cv." >&2 echo " Update your secrets tool: git -C \"$SCRIPT_DIR\" pull" >&2 fi return 0 } # Resolve the on-disk path of an external blob for READING. Tries the v2 suffix # (.properties.age) first, then falls back to the v1 (.gradle-properties.age) for # `properties` externals, so an upgraded client finds the blob whichever format # wrote it (additive v2 — EGB-712). `file` externals share one suffix in both # formats. Echoes the path of the blob that exists; if neither exists, echoes the # canonical v2 path so the caller's "no blob" message reads sensibly. Read-only. _resolve_external_blob_read() { local project="$1" slug="$2" mtype="$3" local base="$SECRETS_DIR/$project/external/$slug" case "$mtype" in file) echo "$base.file.age" ;; properties|gradle-properties) if [ -f "$base.properties.age" ]; then echo "$base.properties.age" elif [ -f "$base.gradle-properties.age" ]; then echo "$base.gradle-properties.age" else echo "$base.properties.age" fi ;; *) echo "$base.$mtype.age" ;; esac } # The on-disk path(s) to WRITE for an external blob, one per line. For a # `properties` external this is the v2 suffix (.properties.age) ALWAYS, plus the # v1 suffix (.gradle-properties.age) WHEN a v1 twin already exists in the store # (dual-write keeps old clients fresh; a brand-new external is v2-only — the # intended forcing function, additive v2 / EGB-712). `file` externals have a # single suffix in both formats. Independent of the store marker. _external_blob_write_targets() { local project="$1" slug="$2" mtype="$3" local base="$SECRETS_DIR/$project/external/$slug" case "$mtype" in file) echo "$base.file.age" ;; properties|gradle-properties) echo "$base.properties.age" [ -f "$base.gradle-properties.age" ] && echo "$base.gradle-properties.age" ;; *) echo "$base.$mtype.age" ;; esac } # 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" # Entries come from .secrets.json (EGB-677) plus any legacy # .secrets-files entries the manifest doesn't cover yet. local entries entries=$(_external_entries_for_push "$root") [ -n "$entries" ] || 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" "$mtype"; 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 if [ "$mtype" = "file" ]; then # EGB-652: whole-file sync — encrypt the file verbatim (binary-safe). mkdir -p "$SECRETS_DIR/$project/external" local fslug; fslug=$(_secrets_files_slug "$mpath") # EGB-712 dual-write × EGB-283 multi-recipient: write every target # (v2 + any v1 twin) encrypted to the full recipient set. local wt while IFS= read -r wt; do [ -n "$wt" ] || continue age "${RECIPIENT_ARGS[@]}" -o "$wt" "$expanded" done < <(_external_blob_write_targets "$project" "$fslug" file) info "Encrypted file $mpath" pushed=$((pushed + 1)) 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") # EGB-712 dual-write × EGB-283 multi-recipient: write every target # (v2 + any v1 twin) encrypted to the full recipient set. local wt while IFS= read -r wt; do [ -n "$wt" ] || continue age "${RECIPIENT_ARGS[@]}" -o "$wt" "$tmp" done < <(_external_blob_write_targets "$project" "$slug" "$mtype") rm -f "$tmp" info "Extracted $found key(s) from $mpath" pushed=$((pushed + 1)) done <<< "$entries" [ "$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" # .secrets.json wins entirely when present (EGB-677); legacy # .secrets-files only drives manifest-less projects. local entries entries=$(_external_entries_for_pull "$root") [ -n "$entries" ] || 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" "$mtype"; then echo "WARNING: skipping unsafe external target: $mpath" >&2 continue fi local slug; slug=$(_secrets_files_slug "$mpath") local blob; blob=$(_resolve_external_blob_read "$project" "$slug" "$mtype") 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 if [ "$mtype" = "file" ]; then # EGB-652: whole-file restore — decrypt next to the target (same # filesystem -> atomic mv), back up any existing target, mode 600, # TOCTOU symlink recheck before the write into $HOME. local fdir; fdir=$(dirname "$expanded") mkdir -p "$fdir" local ftmp; ftmp=$(mktemp "$fdir/.secrets-file.XXXXXX") || { echo "WARNING: mktemp failed for $mpath; skipping." >&2; continue; } if ! age -d -i "$KEY_FILE" -o "$ftmp" "$blob"; then rm -f "$ftmp" die "Decryption failed for external target $mpath." fi chmod 600 "$ftmp" 2>/dev/null || true if [ -L "$expanded" ]; then rm -f "$ftmp" echo "WARNING: $expanded became a symlink; skipping restore." >&2 continue fi if [ -f "$expanded" ] && ! cmp -s "$expanded" "$ftmp"; then cp "$expanded" "$expanded.secrets-bak" 2>/dev/null || true fi mv "$ftmp" "$expanded" info "Restored file $expanded" 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 <<< "$entries" } # ─── Manifest (.secrets.json) — EGB-677 store format v2, stage 1 ────── # # A committed, project-root JSON manifest declaring everything the project # syncs. Stage 1 is ADDITIVE: the manifest is read alongside the existing # dotenv globs and `.secrets-files`; the v1 store layout is unchanged. # # { # "version": 2, # "options": { "autoAdd": true }, # "dotenv": [".env", "packages/web/.env.development"], # "external": [ # { "type": "properties", "path": "~/.gradle/gradle.properties", # "keys": ["beaconClerkPk"] }, # { "type": "file", "path": "~/keystores/beacon-upload.keystore" } # ] # } # # Security posture mirrors `.secrets-store`/`.secrets-files`: symlinked # manifests are refused, dotenv paths are confined to the project root # (no `..`, no absolute paths, conservative charset — `@` allowed for # npm-scoped workspace dirs), and every jq-extracted string is # re-validated before any filesystem use. jq is required only when a # manifest exists or is being written — manifest-less projects keep # working with zero new dependencies. SECRETS_JSON_NAME=".secrets.json" MANIFEST_VERSION=2 # Validate a project-relative dotenv path from the manifest (or `secrets # add`). The path is attacker-influenced in team repos (committed file), # so this is a security rail, not just hygiene. Prints an error and # returns 1 when unsafe. _validate_dotenv_rel_path() { local p="$1" if [ -z "$p" ]; then echo "ERROR: $SECRETS_JSON_NAME: empty dotenv path." >&2 return 1 fi case "$p" in /*) echo "ERROR: $SECRETS_JSON_NAME: '$p' is absolute — dotenv paths must be project-relative." >&2 return 1 ;; *..*) echo "ERROR: $SECRETS_JSON_NAME: '$p' contains '..' — dotenv paths must be project-relative (no traversal)." >&2 return 1 ;; *[!A-Za-z0-9@/._-]*) echo "ERROR: $SECRETS_JSON_NAME: unsafe characters in '$p'. Paths may contain letters, digits, @ / . _ - only." >&2 return 1 ;; esac return 0 } # Validate the manifest file itself (existence assumed checked by caller): # refuse symlinks, malformed JSON, and unsupported schema versions — each # with a directed error. $1 = manifest path. _check_manifest_file() { local manifest="$1" if [ -L "$manifest" ]; then die "$manifest is a symlink. Refusing to read it. A committed symlink could point the manifest at attacker-chosen content. Replace it with a regular file." fi check_cmd jq local jq_err if ! jq_err=$(jq -e . "$manifest" 2>&1 >/dev/null); then die "$manifest: invalid JSON. $jq_err Fix the syntax (or delete the file and re-run 'secrets add' / 'secrets push')." fi local ver ver=$(jq -r '.version // "missing"' "$manifest") if [ "$ver" != "$MANIFEST_VERSION" ]; then die "$manifest: manifest version $ver is not supported. This client supports version $MANIFEST_VERSION. If the manifest was written by a newer secrets, upgrade this machine: git -C $SCRIPT_DIR pull" fi } # Canonically (re)write the manifest: sorted keys, sorted+deduped dotenv, # atomic tmp+mv in the project dir. stdin = the new JSON document. _write_manifest_canonical() { local manifest="$1" local dir; dir=$(dirname "$manifest") local tmp; tmp=$(mktemp "$dir/.secrets-json.XXXXXX") || return 1 if ! jq --sort-keys '.dotenv |= ((. // []) | unique | sort)' > "$tmp"; then rm -f "$tmp" return 1 fi mv "$tmp" "$manifest" } # `secrets add ` — the explicit manifest writer. Creates # .secrets.json on first use; validates and dedupes thereafter. cmd_add() { local p="${1:-}" [ -n "$p" ] || die "Usage: secrets add " # Normalize a leading ./ p="${p#./}" _validate_dotenv_rel_path "$p" || exit 1 if [ ! -f "$PWD/$p" ]; then die "'$p' not found in $PWD. Create the file first, then re-run: secrets add $p" fi check_cmd jq local manifest="$PWD/$SECRETS_JSON_NAME" if [ -e "$manifest" ]; then _check_manifest_file "$manifest" jq --arg p "$p" '.dotenv = ((.dotenv // []) + [$p])' "$manifest" \ | _write_manifest_canonical "$manifest" || die "Failed to update $manifest" else jq -n --arg p "$p" '{version: '"$MANIFEST_VERSION"', dotenv: [$p]}' \ | _write_manifest_canonical "$manifest" || die "Failed to write $manifest" fi info "Added '$p' to $SECRETS_JSON_NAME" info "Commit the manifest so other machines pick it up. To undo: edit $SECRETS_JSON_NAME and remove the entry." } # Emit "\t\t" tuples from a .secrets.json external[] # array — the same wire format _parse_secrets_files_manifest produces, so # push_external_files / pull_external_files consume either source # unchanged. JSON type 'properties' maps to the legacy tuple token # 'gradle-properties' so blob suffixes (and existing store blobs) stay # stable in stage 1. Applies the same conservative charset checks as the # legacy parser — jq guarantees well-formed JSON, not safe VALUES. _json_external_entries() { local manifest="$1" check_cmd jq local etype epath ekeys while IFS=$'\t' read -r etype epath ekeys; do [ -n "$etype" ] || continue case "$etype" in properties|gradle-properties) etype="gradle-properties" if [ -z "$ekeys" ]; then echo "WARNING: $SECRETS_JSON_NAME: properties entry '$epath' has no keys. Skipping." >&2 continue fi ;; file) if [ -n "$ekeys" ]; then echo "WARNING: $SECRETS_JSON_NAME: 'file' entries take no keys ('$epath' lists '$ekeys'). Skipping." >&2 continue fi ;; *) echo "WARNING: $SECRETS_JSON_NAME: unknown external type '$etype' (supported: properties file). Skipping." >&2 continue ;; esac case "$epath" in ''|*[!A-Za-z0-9/._~-]*|*..*) echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in external path '$epath'. Skipping." >&2 continue ;; esac case "$ekeys" in *[!A-Za-z0-9._\ -]*) echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in key list for '$epath'. Skipping." >&2 continue ;; esac printf '%s\t%s\t%s\n' "$etype" "$epath" "$ekeys" done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$manifest") } # EGB-701 item 2: the read guards for the two external-manifest sources, # factored out of _external_entries_for_push/_pull so they can't drift. # # _json_readable — true when a .secrets.json is a safe regular file to read. # A symlinked manifest is treated as absent and silently ignored: it's the # project's own committed file, so a symlink there is just skipped (the fatal # symlink refusal lives in _check_manifest_file, used by the linting paths). _json_readable() { [ -f "$1" ] && [ ! -L "$1" ] } # _legacy_readable — true when a legacy .secrets-files is a safe regular file # to read, warning (and returning false) when it exists but is a symlink: a # symlinked legacy manifest's target is attacker-influenceable, so never follow # it. A missing or non-regular file returns false silently. _legacy_readable() { local legacy="$1" [ -e "$legacy" ] || return 1 if [ -L "$legacy" ]; then echo "WARNING: $legacy is a symlink; ignoring." >&2 return 1 fi [ -f "$legacy" ] } # External tuples for PUSH: .secrets.json entries first, then legacy # .secrets-files entries whose (type, path) the manifest doesn't cover — # the absorb set, which cmd_push folds into the manifest after a # successful push so the two sources converge. _external_entries_for_push() { local root="$1" local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME" local seen="" t p k if _json_readable "$json"; then while IFS=$'\t' read -r t p k; do [ -n "$t" ] || continue printf '%s\t%s\t%s\n' "$t" "$p" "$k" seen="$seen$t|$p"$'\n' done < <(_json_external_entries "$json") fi if _legacy_readable "$legacy"; then while IFS=$'\t' read -r t p k; do [ -n "$t" ] || continue case "$seen" in *"$t|$p"$'\n'*) continue ;; esac printf '%s\t%s\t%s\n' "$t" "$p" "$k" done < <(_parse_secrets_files_manifest "$legacy") fi } # External tuples for PULL: the manifest wins entirely when present; # legacy .secrets-files is only consulted in manifest-less projects. _external_entries_for_pull() { local root="$1" local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME" if _json_readable "$json"; then # A regular (non-symlink) legacy file alongside the manifest is superseded: # warn but don't read it. _json_readable is the "plain regular file" test — # exactly the supersede condition (and unlike _legacy_readable it stays # silent on a symlink, matching the original no-warn-on-symlink behavior). if _json_readable "$legacy"; then echo "WARNING: $legacy is superseded by $SECRETS_JSON_NAME and was ignored on pull. Run 'secrets push' to absorb it, then delete it." >&2 fi _json_external_entries "$json" return 0 fi _legacy_readable "$legacy" && _parse_secrets_files_manifest "$legacy" return 0 } # JSON array of legacy .secrets-files entries NOT yet in the manifest — # what cmd_push absorbs. gradle-properties becomes 'properties' on the # JSON side. Parser warnings suppressed (push_external_files re-parses # and warns once). _legacy_absorb_json() { local root="$1" local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME" local out="[]" if [ ! -f "$legacy" ] || [ -L "$legacy" ]; then printf '%s' "$out" return 0 fi local seen="" if [ -f "$json" ] && [ ! -L "$json" ]; then seen=$(jq -r '.external // [] | .[] | ((if .type == "properties" then "gradle-properties" else .type end) + "|" + .path)' "$json") fi local t p k s found jtype while IFS=$'\t' read -r t p k; do [ -n "$t" ] || continue found=0 while IFS= read -r s; do [ "$s" = "$t|$p" ] && { found=1; break; }; done <<< "$seen" [ "$found" -eq 1 ] && continue jtype="$t"; [ "$t" = "gradle-properties" ] && jtype="properties" out=$(printf '%s' "$out" | jq --arg type "$jtype" --arg path "$p" --arg keys "$k" \ '. + [if $type == "file" then {type: $type, path: $path} else {type: $type, path: $path, keys: ($keys | split(" ") | map(select(length > 0)))} end]') done < <(_parse_secrets_files_manifest "$legacy" 2>/dev/null) printf '%s' "$out" } # Quietly emit "ws-dir/basename" for every env file in a package.json # workspace under . Emits nothing (and never dies) when is # not a workspace monorepo or jq is unavailable — plain `push` calls this # speculatively so a new workspace's env files keep getting discovered # after the one-time --workspaces generator run (EGB-677 E13). _maybe_workspace_env_files() { local root="$1" [ -f "$root/package.json" ] || return 0 command -v jq >/dev/null 2>&1 || return 0 jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 || return 0 local ws f while IFS= read -r ws; do [ -n "$ws" ] || continue if collect_env_files "$root/$ws"; then for f in "${COLLECTED_FILES[@]}"; do printf '%s/%s\n' "$ws" "$(basename "$f")" done fi done < <(get_workspaces "$root") } # ─── End 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 } # Write the store-level .gitignore. Critical: the `key.txt` line is what # keeps the private key out of `git add -A` during push/rekey. write_store_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 } # Restore store-level protections if missing. A cloned store has no # pre-commit hook (hooks aren't cloned), and a half-initialized store may # lack .gitignore — without it, `git add -A` would commit key.txt. ensure_store_protections() { # Content-aware: a present-but-corrupted .gitignore missing the key.txt # line is just as dangerous as a missing one. if [ ! -f "$SECRETS_DIR/.gitignore" ] || ! grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"; then write_store_gitignore info "Restored store .gitignore" fi # .gitignore can't untrack an already-tracked key (legacy damage, or a # past window where .gitignore was missing). Remove it from the index so # the next commit drops it from the tip. if git -C "$SECRETS_DIR" ls-files --error-unmatch key.txt >/dev/null 2>&1; then git -C "$SECRETS_DIR" rm --cached --quiet key.txt echo "WARNING: key.txt was tracked in the store repo — untracked it now." >&2 echo "It may still exist in git history; consider 'secrets rekey' and scrubbing history." >&2 fi if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then mkdir -p "$SECRETS_DIR/.git/hooks" install_hook info "Reinstalled pre-commit hook" fi } # ─── Subcommands ─────────────────────────────────────────────────────── cmd_init() { check_cmd age check_cmd git # EGB-671: flag parsing. --remote wires the encrypted-vault git remote and # establishes an upstream branch (so the first project push won't hit the # commit_and_push_secrets `pull --ff-only` die on a brand-new empty remote). # --yes / non-interactive means init-only: skip the interactive first-add. local remote="" assume_yes=false while [ $# -gt 0 ]; do case "$1" in --remote) [ $# -ge 2 ] || die "--remote requires a URL" case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac remote="$2"; shift 2 ;; --remote=*) remote="${1#--remote=}" [ -n "$remote" ] || die "--remote= requires a value"; shift ;; --yes|-y) assume_yes=true; shift ;; -*) die "Unknown init flag: $1. Usage: secrets init [--remote ] [--yes]" ;; *) die "Unexpected argument to init: $1" ;; esac done resolve_store if [ -d "$SECRETS_DIR/.git" ]; then die "Already initialized at $SECRETS_DIR. Key file preserved." fi # Second-machine trap: a copied key.txt without a repo means the user # should join their existing vault, not init a fresh one. Catch it BEFORE # git init so we don't leave a half-initialized store. if [ -f "$KEY_FILE" ]; then local clone_src="" [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL" die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. If this is a second machine, don't run 'secrets init' — join your existing vault: secrets join --remote $clone_src --key $KEY_FILE Your key file has been left untouched." 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 write_store_gitignore # Stamp the store format (EGB-703): a fresh store is born v2. printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" # Install pre-commit hook mkdir -p "$SECRETS_DIR/.git/hooks" install_hook local pubkey pubkey=$(get_pubkey) # EGB-283: born-multi — seed recipients.txt with this store's public key so # the store is multi-recipient-ready from day one. Committed (not gitignored), # staged by the first push like .secrets-format. printf '# self\n%s\n' "$pubkey" > "$RECIPIENTS_FILE" info "Done! Your public key is:" echo " $pubkey" if [ -n "$remote" ]; then _init_wire_remote "$remote" else echo "" echo "Next steps:" echo " 1. Add a remote: secrets init --remote (or: git -C $SECRETS_DIR remote add origin )" echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" echo " 3. On the other machine: secrets join --remote --key " fi # Interactive first-add (EGB-671): only when truly interactive AND not --yes. # Require BOTH stdin and stdout to be ttys — bats/CI capture a command's # stdout (so `[ -t 1 ]` is false under automation even when stdin is still # the terminal), which is the reliable "don't prompt" signal. Default is No. if [ "$assume_yes" != true ] && [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then _init_first_add fi } # EGB-671: wire the encrypted-vault remote and establish an upstream branch. # Commits the born-v2 store (so .secrets-format etc. exist on the remote) and # push -u, so a later `secrets push` pulls --ff-only against a real upstream # instead of dying on a non-existent branch. _init_wire_remote() { local remote="$1" git -C "$SECRETS_DIR" remote add origin "$remote" ensure_store_protections _stamp_writer_version git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "Initialize secrets store (format v2)" >/dev/null 2>&1 || true local br br=$(git -C "$SECRETS_DIR" symbolic-ref --short HEAD 2>/dev/null || echo main) if git -C "$SECRETS_DIR" push -u origin "$br" >/dev/null 2>&1; then info "Wired remote origin=$remote and pushed the initial store (upstream: origin/$br)." else info "Added remote origin=$remote, but the initial push failed." echo " Create the PRIVATE repo first, then: git -C $SECRETS_DIR push -u origin $br" >&2 fi echo "" echo "Next: copy $KEY_FILE to your other machine, then run there:" echo " secrets join --remote $remote --key " } # EGB-671: interactive "add your first project" post-step. Reads from /dev/tty # so it never collides with a piped stdin. Default No. Drives the existing # push path (cmd_push is $PWD-bound) by cd'ing into the chosen project dir. _init_first_add() { printf "Add a project's secrets to the vault now? [y/N] " > /dev/tty 2>/dev/null || return 0 local ans="" read -r ans < /dev/tty 2>/dev/null || return 0 case "$ans" in [Yy]*) ;; *) return 0 ;; esac printf "Path to the project directory: " > /dev/tty 2>/dev/null || return 0 local dir="" read -r dir < /dev/tty 2>/dev/null || return 0 [ -n "$dir" ] || return 0 case "$dir" in "~") dir="$HOME" ;; "~/"*) dir="$HOME/${dir#\~/}" ;; esac if [ ! -d "$dir" ]; then echo "Not a directory: $dir — skipping first-add. Run 'secrets push' from a project later." > /dev/tty 2>/dev/null || true return 0 fi ( cd "$dir" && cmd_push ) || true return 0 } # EGB-671: second-machine onboarding. Clone the vault, install the key, and # decrypt-test it BEFORE declaring success — the one thing a hand-copied # README sequence never did (a mis-copied key fails silently at first pull). # Security logic (path rails on --key/--store, URL sanitization) lives in the # audited core, reused — never re-implemented in a standalone install script. cmd_join() { check_cmd age check_cmd git local remote="" keyfile="" while [ $# -gt 0 ]; do case "$1" in --remote) [ $# -ge 2 ] || die "--remote requires a URL" case "$2" in --|-*) die "--remote value looks like a flag: $2" ;; esac remote="$2"; shift 2 ;; --remote=*) remote="${1#--remote=}" [ -n "$remote" ] || die "--remote= requires a value"; shift ;; --key) [ $# -ge 2 ] || die "--key requires a path" case "$2" in --|-*) die "--key value looks like a flag: $2" ;; esac keyfile="$2"; shift 2 ;; --key=*) keyfile="${1#--key=}" [ -n "$keyfile" ] || die "--key= requires a value"; shift ;; -*) die "Unknown join flag: $1. Usage: secrets join --remote --key " ;; *) die "Unexpected argument to join: $1" ;; esac done resolve_store [ -n "$remote" ] || die "secrets join requires --remote Usage: secrets join --remote --key " [ -n "$keyfile" ] || die "secrets join requires --key This is the age key (key.txt) from your first machine. Usage: secrets join --remote --key " if [ -d "$keyfile" ]; then die "--key must point to the key FILE, not a directory: $keyfile Did you mean: --key $keyfile/key.txt ?" fi [ -e "$keyfile" ] || die "Key file not found: $keyfile Copy key.txt from your first machine (AirDrop/scp/USB) and pass its path." [ -r "$keyfile" ] || die "Key file not readable: $keyfile" if [ -e "$SECRETS_DIR" ]; then die "A store already exists at $SECRETS_DIR. 'secrets join' clones a fresh vault — it won't clobber an existing one. If you meant to refresh it, run 'secrets pull' instead, or remove $SECRETS_DIR first." fi info "Joining vault: cloning $remote → $SECRETS_DIR" if ! git clone "$remote" "$SECRETS_DIR" >/dev/null 2>&1; then rm -rf "$SECRETS_DIR" die "Failed to clone $remote Check the URL and that you have access to the repo." fi # Install the key BEFORE anything that decrypts, at mode 600. cp "$keyfile" "$SECRETS_DIR/key.txt" chmod 600 "$SECRETS_DIR/key.txt" KEY_FILE="$SECRETS_DIR/key.txt" ensure_store_protections if ! get_pubkey >/dev/null 2>&1; then die "The file you passed to --key is not a valid age identity: $keyfile Your store was cloned to $SECRETS_DIR; replace key.txt with a valid key and run 'secrets pull'." fi # Verify gate: decrypt-test every blob. An EMPTY store returns 0 from # _verify_all ("nothing to check") — that proves nothing about the key, so # join must NOT report VERIFIED in that case (EGB-671 / E-S1b). local blob_count blob_count=$(find "$SECRETS_DIR" -type f -name '*.age' 2>/dev/null | wc -l | tr -d ' ') if [ "$blob_count" -eq 0 ]; then info "Joined $SECRETS_DIR — the vault is empty, so there's nothing to verify yet." echo "Next: run 'secrets pull' in a project once secrets have been pushed from another machine." return 0 fi if _verify_all >/dev/null 2>&1; then info "VERIFIED — your key decrypts all $blob_count blob(s). You've joined the vault." echo "Next: run 'secrets pull' in any project to restore its secrets." return 0 fi die "Your key does NOT decrypt this vault ($blob_count blob(s) failed). This is almost always the wrong key.txt. The store is at $SECRETS_DIR; replace key.txt with the correct key and run 'secrets pull', or remove $SECRETS_DIR and re-run 'secrets join' with the right --key." } # 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" 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 "${RECIPIENT_ARGS[@]}" -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 # Must run AFTER the pull and immediately before `git add -A`: the pull # can remove or alter .gitignore (remote history that lacks it), and a # store missing the key.txt line would stage and push the private key. ensure_store_protections _stamp_writer_version 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 } # Manifest-aware push (EGB-677 stage 1). Discovery (root globs + a quiet # package.json workspace re-scan when a manifest exists) feeds the # manifest as a GENERATOR; the sync itself runs FROM the manifest. The # v1 store layout is unchanged: root files land at /.age, # nested entries at /.age (same shape -w always used). cmd_push() { local frozen=false dry_run=false explicit_project="" while [ $# -gt 0 ]; do case "$1" in --frozen) frozen=true; shift ;; --dry-run) dry_run=true; shift ;; -*) die "Unknown push flag: $1. Usage: secrets push [--frozen] [--dry-run] [project]" ;; *) explicit_project="$1"; shift ;; esac done check_cmd age check_cmd git resolve_store check_initialized check_key local project project=$(derive_project_name "$explicit_project") info "Pushing secrets for project: $project" echo_store_if_non_default _load_recipients # ── Manifest read (validated; absence = bootstrap) ── # jq is required only when a manifest exists (authoritative, can't be # ignored) or is being written. Without jq on a manifest-less project, # manifest features are skipped with a notice — clone-and-run for v1 # users survives. local have_jq=true command -v jq >/dev/null 2>&1 || have_jq=false local manifest="$PWD/$SECRETS_JSON_NAME" local have_manifest=false auto_add=true declared="" if [ -e "$manifest" ]; then _check_manifest_file "$manifest" have_manifest=true declared=$(jq -r '.dotenv // [] | .[]' "$manifest") local d while IFS= read -r d; do [ -n "$d" ] || continue _validate_dotenv_rel_path "$d" \ || die "Refusing unsafe dotenv path in $SECRETS_JSON_NAME (paths must be project-relative): $d" done <<< "$declared" # NB: jq's // treats false as empty, so `.options.autoAdd // true` # would silently flip an explicit false back to true. Compare directly. auto_add=$(jq -r '.options.autoAdd | if . == false then "false" else "true" end' "$manifest") fi [ "$frozen" = true ] && auto_add=false # ── Discovery: root globs + workspace re-scan (manifest projects) ── local discovered="" f if collect_env_files "$PWD"; then for f in "${COLLECTED_FILES[@]}"; do discovered="$discovered$(basename "$f")"$'\n' done fi if [ "$have_manifest" = true ]; then discovered="$discovered$(_maybe_workspace_env_files "$PWD")"$'\n' fi # to_add = discovered − declared (deduped; pure bash 3.2, no assoc arrays) local to_add="" e known while IFS= read -r e; do [ -n "$e" ] || continue known=0 while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$declared" [ "$known" -eq 1 ] && continue while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$to_add" [ "$known" -eq 1 ] && continue to_add="$to_add$e"$'\n' done <<< "$discovered" if [ "$dry_run" = true ]; then info "Dry run — nothing encrypted, nothing written." if [ -n "$to_add" ]; then echo "Would add to $SECRETS_JSON_NAME:" while IFS= read -r e; do [ -n "$e" ] && echo " $e"; done <<< "$to_add" else echo "Nothing new to add to $SECRETS_JSON_NAME." fi if [ -n "$declared" ]; then echo "Would sync (declared):" while IFS= read -r e; do [ -n "$e" ] && echo " $e"; done <<< "$declared" fi return 0 fi # ── Build the sync list ── local sync_list="$declared" if [ "$auto_add" = true ] || [ "$have_manifest" = false ]; then sync_list="$declared"$'\n'"$to_add" else while IFS= read -r e; do [ -n "$e" ] || continue echo "WARNING: '$e' is not declared in $SECRETS_JSON_NAME and autoAdd is off — not synced. Run: secrets add $e" >&2 done <<< "$to_add" fi # ── Encrypt FROM the (effective) manifest ── local count=0 rel while IFS= read -r rel; do [ -n "$rel" ] || continue if [ ! -f "$PWD/$rel" ]; then echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but not found in $PWD — skipping." >&2 continue fi case "$rel" in */*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;; *) mkdir -p "$SECRETS_DIR/$project" ;; esac age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel" echo " $rel" count=$((count + 1)) done <<< "$sync_list" [ "$count" -gt 0 ] && info "$project: $count file(s)" local did=0 [ "$count" -gt 0 ] && did=1 if push_external_files "$PWD" "$project"; 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 # ── Manifest write AFTER successful encryption (bootstrap ordering) ── # Two independent reasons to write: dotenv auto-adds, and absorbing a # legacy .secrets-files (gradle-properties → properties) so the two # external sources converge on the manifest. if [ "$have_jq" = false ]; then echo "NOTE: jq not found — skipping $SECRETS_JSON_NAME manifest features (auto-add, absorb). Install jq to enable them." >&2 fi local absorbed_json="[]" n_absorbed=0 if [ "$frozen" = false ] && [ "$have_jq" = true ]; then absorbed_json=$(_legacy_absorb_json "$PWD") n_absorbed=$(printf '%s' "$absorbed_json" | jq 'length') fi local write_adds=false if [ -n "$to_add" ] && { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then write_adds=true fi if [ "$did" -eq 1 ] && [ "$frozen" = false ] && [ "$have_jq" = true ] \ && { [ "$write_adds" = true ] || [ "$n_absorbed" -gt 0 ]; }; then local add_json="[]" [ "$write_adds" = true ] && add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))') if [ "$have_manifest" = true ]; then jq --argjson add "$add_json" --argjson ext "$absorbed_json" \ '.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \ | _write_manifest_canonical "$manifest" || die "Failed to update $manifest" else # EGB-671 / EGB-677 contract #2: scaffolding the project's FIRST manifest. # Record an explicit, committed options.autoAdd value. Ask once when # interactive (read from /dev/tty so a piped stdin never collides); # otherwise write the tool default (ON) explicitly so the value is # committed and team-shared rather than left implicit. local autoadd_commit="true" # Require BOTH stdin and stdout to be ttys (bats/CI capture stdout, so # `[ -t 1 ]` is false under automation — never block a scripted push). if [ -t 0 ] && [ -t 1 ] && [ -e /dev/tty ]; then printf "Auto-track new env files in this project as you add them? [Y/n] " > /dev/tty 2>/dev/null || true local _aa="" read -r _aa < /dev/tty 2>/dev/null || _aa="" case "$_aa" in [Nn]*) autoadd_commit="false" ;; *) autoadd_commit="true" ;; esac fi jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" --argjson autoadd "$autoadd_commit" \ '{version: '"$MANIFEST_VERSION"', dotenv: $add, options: {autoAdd: $autoadd}} | if ($ext | length) > 0 then .external = $ext else . end' \ | _write_manifest_canonical "$manifest" || die "Failed to write $manifest" fi if [ "$write_adds" = true ]; then while IFS= read -r e; do [ -n "$e" ] && info "Added '$e' to $SECRETS_JSON_NAME" done <<< "$to_add" fi if [ "$n_absorbed" -gt 0 ]; then info "Absorbed $n_absorbed entr(y/ies) from $SECRETS_FILES_NAME into $SECRETS_JSON_NAME (gradle-properties → properties). $SECRETS_FILES_NAME can be deleted." fi info "Commit the manifest so other machines pick it up. To undo an entry: edit $SECRETS_JSON_NAME (or use 'secrets push --frozen' to skip auto-add)." 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 _load_recipients local total=0 # Push root env files (if any) if push_dir_to_project "$root" "$monorepo_name"; 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"; 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"; 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 # ── Manifest-driven pull (EGB-677 stage 1) ── # With a .secrets.json present, the manifest decides what restores and # where (nested entries get their directories created). The dotenv rail # runs again at restore time — warn+skip on pull, never die, so one bad # entry can't block the rest of the restore. local manifest="$PWD/$SECRETS_JSON_NAME" if [ -e "$manifest" ]; then _check_manifest_file "$manifest" local declared n_external declared=$(jq -r '.dotenv // [] | .[]' "$manifest") n_external=$(jq -r '.external // [] | length' "$manifest") if [ -z "$declared" ] && [ "$n_external" -eq 0 ]; then echo "WARNING: $SECRETS_JSON_NAME declares nothing to pull (empty manifest). Run 'secrets push' on a machine that has the files." >&2 ensure_store_protections return 0 fi if [ -n "$declared" ] && [ ! -d "$SECRETS_DIR/$project" ]; then die "Project '$project' not found. Run: secrets list" fi local count=0 rel while IFS= read -r rel; do [ -n "$rel" ] || continue if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then echo "WARNING: skipping unsafe dotenv path from $SECRETS_JSON_NAME: $rel" >&2 continue fi local blob="$SECRETS_DIR/$project/${rel}.age" if [ ! -f "$blob" ]; then echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but has no encrypted data in the store yet. Run 'secrets push' on a machine that has it. Skipping." >&2 continue fi case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac # EGB-671 (DX-3): a wrong-but-structurally-valid key must NOT fail # silently. Die loudly on decrypt failure instead of leaving a partial. if ! age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"; then die "Failed to decrypt '$rel' with the current key ($KEY_FILE). Wrong key for this vault? Run 'secrets verify --all' to check the key." fi if [ ! -s "$target_dir/$rel" ]; then echo "WARNING: Decrypted file '$rel' is empty (an empty source file, or a truncated .age blob)" fi count=$((count + 1)) done <<< "$declared" info "Decrypted $count file(s) into $target_dir" pull_external_files "$PWD" "$project" ensure_store_protections return 0 fi # ── Legacy glob pull (manifest-less projects; unchanged) ── # 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" # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent. if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then die "Failed to decrypt '$name' with the current key ($KEY_FILE). Wrong key for this vault? Run 'secrets verify --all' to check the key." fi # Integrity check: verify non-empty if [ ! -s "$outfile" ]; then echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)" fi count=$((count + 1)) done info "Decrypted $count file(s) into $target_dir" # EGB-701 item 3: the globs above are non-recursive, so a nested dotenv blob # (/.age) written by a manifest-driven push on another # machine is invisible here — silently restored nothing, counted nothing. # external/.age blobs are restored by pull_external_files, so exclude # them. Warn (don't die) so a manifest-less pull never under-restores in # silence; the fix is a committed .secrets.json, which the recursive # manifest-driven branch above handles correctly. local nested nested=$(find "$SECRETS_DIR/$project" -mindepth 2 -type f -name '*.age' \ -not -path "$SECRETS_DIR/$project/external/*" 2>/dev/null) if [ -n "$nested" ]; then echo "WARNING: this project has nested encrypted files the manifest-less pull can't restore:" >&2 while IFS= read -r nf; do [ -n "$nf" ] || continue local rel="${nf#"$SECRETS_DIR/$project/"}" echo " ${rel%.age}" >&2 done <<< "$nested" echo " Add a $SECRETS_JSON_NAME manifest (run 'secrets push' on a machine that has these files) so they restore." >&2 fi # Merge any external files (.secrets-files) declared in this project. pull_external_files "$PWD" "$project" # Reinstall hook / store .gitignore if missing ensure_store_protections } # 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" # EGB-671 (DX-3): die loudly on decrypt failure (wrong key) — never silent. if ! age -d -i "$KEY_FILE" -o "$outfile" "$f"; then die "Failed to decrypt '$name' with the current key ($KEY_FILE). Wrong key for this vault? Run 'secrets verify --all' to check the key." fi if [ ! -s "$outfile" ]; then echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a 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 / store .gitignore if missing ensure_store_protections } cmd_list() { local json=0 case "${1:-}" in --json) json=1 ;; "") ;; *) die "Usage: secrets list [--json]" ;; esac resolve_store check_initialized if [ "$json" -eq 1 ]; then cmd_list_json return fi local found=0 for dir in "$SECRETS_DIR"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") # Skip hidden dirs [[ "$project" == .* ]] && continue echo "$project:" # Recurse the whole project tree so nested manifest blobs # (/.age) are visible, not just top-level entries. # External blobs (external/.age) are labelled distinctly. while IFS= read -r f; do [ -f "$f" ] || continue local rel rel=${f#"$dir"} rel=${rel%.age} case "$rel" in external/*) echo " [external] ${rel#external/}" ;; *) echo " $rel" ;; esac found=1 done < <(find "$dir" -type f -name '*.age' | sort) 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 } # EGB-699: machine-readable listing for tooling/CI (feeds EGB-671 install # scripts). Contract: a single JSON object on stdout — # {"store": "", "projects": [{"name", "entries": [...]}]} # where each entry is {"type":"dotenv","path":} or # {"type":"external","subtype":"properties"|"file","path":}. Mirrors the # recursive store walk the human `list` uses (nested /.age + # external/.age). jq does the assembly so paths are escaped correctly; # stdout stays pure JSON (the human store hint is suppressed in this mode). cmd_list_json() { check_cmd jq { for dir in "$SECRETS_DIR"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") [[ "$project" == .* ]] && continue # Marker line so a project with zero blobs still appears (mirrors the # human header), grouped via jq below. printf 'project\t%s\n' "$project" local f rel name while IFS= read -r f; do [ -f "$f" ] || continue rel=${f#"$dir"} rel=${rel%.age} case "$rel" in external/*) name=${rel#external/} case "$name" in *.file) printf 'entry\t%s\texternal\tfile\t%s\n' "$project" "${name%.file}" ;; *.properties) printf 'entry\t%s\texternal\tproperties\t%s\n' "$project" "${name%.properties}" ;; *.gradle-properties) printf 'entry\t%s\texternal\tproperties\t%s\n' "$project" "${name%.gradle-properties}" ;; *) printf 'entry\t%s\texternal\tunknown\t%s\n' "$project" "$name" ;; esac ;; *) printf 'entry\t%s\tdotenv\t\t%s\n' "$project" "$rel" ;; esac done < <(find "$dir" -type f -name '*.age' | sort) done } | jq -R -n --arg store "$SECRETS_DIR" ' [inputs | split("\t")] as $lines | ($lines | map(select(.[0] == "project") | .[1]) | unique) as $names | { store: $store, projects: ($names | map(. as $p | { name: $p, entries: [ $lines[] | select(.[0] == "entry" and .[1] == $p) | if .[2] == "external" then { type: "external", subtype: .[3], path: .[4] } else { type: "dotenv", path: .[4] } end ] })) }' } 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 } # Decrypt every blob in the store with the local key and re-encrypt each to the # currently-loaded RECIPIENT_ARGS, then commit + push. The caller MUST have run # _load_recipients (or set RECIPIENT_ARGS) and check_key first. Aborts with the # store untouched on any decrypt failure (you must be a current recipient). # Shared by recipients add/rm, reencrypt, and multi-recipient rekey. _reencrypt_all() { local commit_msg="$1" local tmpdir tmpdir=$(mktemp -d) trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM info "Decrypting all blobs with your key..." local file_count=0 dir project f rel dest for dir in "$SECRETS_DIR"/*/; do [ -d "$dir" ] || continue project=$(basename "$dir") case "$project" in .*) continue ;; esac mkdir -p "$tmpdir/$project" while IFS= read -r f; do [ -f "$f" ] || continue rel=${f#"$dir"}; rel=${rel%.age} dest="$tmpdir/$project/$rel" mkdir -p "$(dirname "$dest")" if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then die "Decryption failed for $project/$rel (are you a current recipient?). Aborted; store unchanged." fi file_count=$((file_count + 1)) done < <(find "$dir" -type f -name '*.age') done if [ "$file_count" -eq 0 ]; then rm -rf "$tmpdir"; trap - EXIT INT TERM info "No encrypted blobs in the store — nothing to re-encrypt." return 0 fi local rc=$(( ${#RECIPIENT_ARGS[@]} / 2 )) info "Re-encrypting $file_count blob(s) to $rc recipient(s)..." for dir in "$tmpdir"/*/; do [ -d "$dir" ] || continue project=$(basename "$dir") mkdir -p "$SECRETS_DIR/$project" while IFS= read -r f; do [ -f "$f" ] || continue rel=${f#"$dir"} mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")" age "${RECIPIENT_ARGS[@]}" -o "$SECRETS_DIR/$project/${rel}.age" "$f" done < <(find "$dir" -type f) done ensure_store_protections git -C "$SECRETS_DIR" add -A git -C "$SECRETS_DIR" commit -m "$commit_msg" >/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 re-encrypted secrets to remote" else info "Committed re-encrypted secrets locally (no remote configured)" fi rm -rf "$tmpdir"; trap - EXIT INT TERM } cmd_reencrypt() { check_cmd age check_cmd git resolve_store check_initialized check_key if [ ! -e "$RECIPIENTS_FILE" ]; then info "No $RECIPIENTS_FILE_NAME — single-key store; re-encrypting to your own key only. Add teammates with 'secrets recipients add'." fi _load_recipients _reencrypt_all "reencrypt: re-encrypt all to current recipients" } cmd_rekey() { check_cmd age check_cmd git resolve_store check_initialized check_key # EGB-283: on a multi-recipient store, rekey means "re-encrypt every blob to # the current recipients.txt set" — NOT a new keypair (rotating an identity is # the member's own age-keygen + recipients rm/add). Legacy stores (no # recipients.txt) keep the original generate-new-keypair behavior below. if [ -e "$RECIPIENTS_FILE" ]; then _load_recipients info "Multi-recipient store — re-encrypting to $RECIPIENTS_FILE_NAME (no new key generated)." _reencrypt_all "rekey: re-encrypt all to current recipients" return 0 fi # ── Legacy single-key rotation (unchanged) ── # 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" # Walk the WHOLE project tree, not just its top level. Manifest dotenv # entries can nest (/.age) and external blobs live in # /external/.age. A non-recursive glob would skip both, # leaving them encrypted under the old key = permanently undecryptable # after rotation (silent data loss). `find` is bash-3.2 safe and recurses. while IFS= read -r f; do [ -f "$f" ] || continue local rel dest rel=${f#"$dir"} # path relative to the project dir (keeps .age) rel=${rel%.age} # strip the .age suffix → original relpath dest="$tmpdir/$project/$rel" mkdir -p "$(dirname "$dest")" if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then die "Decryption failed for $project/$rel. Rekey aborted. Old key preserved." fi file_count=$((file_count + 1)) done < <(find "$dir" -type f -name '*.age') 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. `find -type f` recurses into nested dotenv dirs and # external/ and natively includes dotfiles (decrypted dotenv files like # "$tmpdir/p/.env"), which a bare "*" glob would silently skip — leaving # their blobs on the old key (undecryptable after rotation). The walk mirrors # the recursive decrypt above so every blob round-trips back to its relpath. for dir in "$tmpdir"/*/; do [ -d "$dir" ] || continue local project project=$(basename "$dir") mkdir -p "$SECRETS_DIR/$project" while IFS= read -r f; do [ -f "$f" ] || continue local rel rel=${f#"$dir"} # path relative to the project temp dir mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")" age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$f" done < <(find "$dir" -type f) done # Commit and push (heal .gitignore first so add -A can't stage key.txt) ensure_store_protections _stamp_writer_version 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_recipients() { resolve_store local sub="${1:-list}" [ $# -gt 0 ] && shift case "$sub" in list) _recipients_list ;; add) _recipients_add "$@" ;; rm|remove) _recipients_rm "$@" ;; *) die "Unknown recipients subcommand: '$sub'. Usage: secrets recipients [list|add [--name N]|rm [--yes]]" ;; esac } _recipients_list() { check_initialized if [ ! -e "$RECIPIENTS_FILE" ]; then check_key echo "recipients: single-key (no $RECIPIENTS_FILE_NAME)" echo " $(get_pubkey)" return 0 fi _load_recipients # validates the file (dies on bad key / symlink) local count=0 k n while IFS=$'\t' read -r k n; do count=$((count + 1)); done < <(_recipients_dump) echo "recipients: $count (from $RECIPIENTS_FILE_NAME)" while IFS=$'\t' read -r k n; do if [ -n "$n" ]; then echo " $k ($n)"; else echo " $k"; fi done < <(_recipients_dump) } _recipients_add() { check_cmd age check_cmd git check_initialized check_key local key="" name="" while [ $# -gt 0 ]; do case "$1" in --name) [ $# -ge 2 ] || die "--name requires a value."; name="$2"; shift 2 ;; -*) die "Unknown flag: $1. Usage: secrets recipients add [--name