secrets/secrets
Brian Majewski e2ad661da5 feat: store-format-v2 self-describing migration (EGB-703, folds in EGB-700)
Stage 2 of EGB-677. Makes the store self-describing and unifies the legacy
external `properties` blob suffix, via a copy-forward migration that never
destroys data until an explicit, gated finalize.

Scope decision (see eureka): the EGB-677 CEO plan's "flatten dotenv blobs to
basename" was dropped as LOSSY — it discards the restore relpath that makes
the store self-describing and adds basename collisions. Engineering analysis
(4 parallel design agents) showed the store is already relpath-self-describing;
the only real v1→v2 delta is the `properties` suffix. This implements the
minimal, safe v2 that achieves the epic's self-describing goal.

What's added:
- `.secrets-format` marker (committed, one line `2`). Absence ⇒ v1 (every
  pre-EGB-703 store). `_store_format()` reads it; `init` stamps fresh stores
  born-v2. `secrets which` prints `format: vN` (EGB-700 folded in).
- `_external_blob_suffix(type)` — single source of truth for the external
  suffix (v2: gradle-properties → properties; file unchanged). push/pull/verify
  all route through it, so v1 and v2 stores never disagree on blob location.
- `secrets migrate` — per-project copy-forward (writes `.properties.age` twins
  beside v1 blobs; idempotent; needs the project manifest), `--dry-run`
  (reports old→new, writes nothing), `--finalize` (store-wide, the only
  destructive step: gates on `verify --all` green + every v1 blob twinned,
  cuts a `pre-v2-migrate-<sha>` recovery tag, stamps the marker, then drops v1
  blobs; refuses without `--yes`/operator confirmation).

rekey and verify --all stay format-agnostic (recursive find walk) — no change.

21 new bats tests (test/migrate.bats): marker/born-v2, format-aware suffix,
v1 back-compat, dry-run, copy-forward idempotency, no-manifest die, finalize
gates (verify-not-green refusal, untwinned refusal, recovery tag, confirmation),
and full v1→window→finalize round-trip. Updated 4 existing tests for the
born-v2 suffix. Full suite 231/231, bash 3.2 clean.
2026-06-07 16:07:31 -07:00

2466 lines
90 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -euo pipefail
# SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
# secrets — encrypted env file sync between machines
# Uses age key-file encryption + a private git repo.
# F5: explicit HOME check. `set -u` would already error if HOME were unset,
# but the message would be cryptic ("HOME: unbound variable" pointing at
# the SECRETS_DIR default-init line). Check up front with a directed message
# so cron/sudo/CI users know what to fix.
: "${HOME:?HOME is not set; secrets needs a home directory to find or create the store}"
# Capture the user-provided SECRETS_DIR (if any) before defaulting.
# resolve_store() uses this to honor SECRETS_DIR as the legacy escape hatch
# while letting .secrets-store files take precedence per project.
_USER_SECRETS_DIR="${SECRETS_DIR:-}"
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
KEY_FILE="$SECRETS_DIR/key.txt"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Set by resolve_store(). Reports which rule chose SECRETS_DIR.
STORE_SOURCE="default"
# Path to the .secrets-store file that won resolution, if any.
_LAST_FOUND_AT=""
# Remote URL parsed from the active .secrets-store file (optional 2nd token).
# Used by check_initialized to fill in a runnable `git clone <URL> <PATH>`
# in the missing-store error so teammates don't have to ask.
_REMOTE_URL=""
# --store flag value, captured by the pre-pass.
STORE_OVERRIDE=""
# ─── Helpers ───────────────────────────────────────────────────────────
die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }
check_cmd() {
command -v "$1" >/dev/null 2>&1 && 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
return
fi
if [ "$STORE_SOURCE" != "default" ]; then
# Fill in the remote URL when .secrets-store provided one, so the
# teammate can copy-paste the clone command without asking the original
# setter for the URL. Falls back to a placeholder otherwise.
local clone_url
if [ -n "${_REMOTE_URL:-}" ]; then
clone_url="$_REMOTE_URL"
else
clone_url="<their-store-remote>"
fi
die "Store not initialized: $SECRETS_DIR
Resolved from: $STORE_SOURCE
This path doesn't exist on this machine yet.
If you're joining a teammate's existing store:
git clone $clone_url $SECRETS_DIR
# then copy their key.txt to $SECRETS_DIR/key.txt
If you want a fresh new store at this path:
secrets --store $SECRETS_DIR init"
fi
die "Not initialized. Run: secrets init"
}
check_key() {
if [ -f "$KEY_FILE" ]; then
return
fi
if [ "$STORE_SOURCE" != "default" ]; then
die "Key file not found at $KEY_FILE
The store at $SECRETS_DIR exists but has no key.txt.
Resolved from: $STORE_SOURCE
If joining a teammate's store: copy their key.txt to $KEY_FILE.
If this is your own new store: re-run init for this store path."
fi
die "Key file not found at $KEY_FILE. Run: secrets init"
}
get_pubkey() {
age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
}
derive_project_name() {
local explicit="${1:-}"
if [ -n "$explicit" ]; then
echo "$explicit"
return
fi
# Try git remote basename
local remote
remote=$(git config --get remote.origin.url 2>/dev/null || true)
if [ -n "$remote" ]; then
basename "$remote" .git
return
fi
# Fall back to current directory name
basename "$PWD"
}
# ─── Store resolution ──────────────────────────────────────────────────
#
# Resolution order (highest precedence first):
# 1. --store <dir> flag (captured by main pre-pass into STORE_OVERRIDE)
# 2. .secrets-store file in cwd or any ancestor up to but NOT including $HOME
# 3. SECRETS_DIR env var (legacy escape hatch)
# 4. $HOME/.secrets default
#
# .secrets-store file format: first non-empty non-comment line is the store
# spec, optionally followed by a remote URL on the same line. Spec is one of:
# - absolute path (/Users/you/.secrets-work)
# - ~/path (expanded to $HOME/path)
# - bare name (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets)
#
# The optional second token (whitespace-separated) is a git remote URL. It
# is only used as a hint when the store directory does not yet exist on
# the current machine — the missing-store error includes a runnable
# `git clone <url> <path>` for the teammate to copy. Examples:
#
# work
# work git@github.com:acme/work-secrets.git
# ~/.secrets-work https://github.com/acme/work-secrets.git
#
# Comments (#) and CRLF line endings are tolerated. NO shell expansion is
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
# literal characters to prevent code injection from a committed file.
# Take a store spec (path or bare name) and return an absolute directory.
_expand_store_path() {
local path="$1"
case "$path" in
/*)
echo "$path"
;;
'~')
echo "$HOME"
;;
'~/'*)
echo "$HOME/${path#\~/}"
;;
.*|*/*)
# Relative path or path containing /. Treat as path relative to cwd.
echo "$path"
;;
default)
# Sugar: --store default → the canonical default store
echo "$HOME/.secrets"
;;
*)
# Bare name like "work" → $HOME/.secrets-work
echo "$HOME/.secrets-$path"
;;
esac
}
# Read a .secrets-store file. On success, print "<store-spec>\t<remote-url>"
# (URL empty if not provided or rejected as unsafe). The first non-empty
# non-comment line is the active line; the first whitespace splits it into
# the spec and an optional URL.
#
# Security: the URL is later substituted into a copy-paste-ready `git clone`
# command in the missing-store error. An attacker who slips a malicious line
# into a committed .secrets-store could weaponize that copy-paste — e.g.
# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...`
# and a teammate following the directed error would execute the payload.
# We reject URLs containing shell metacharacters, control characters, ANSI
# escapes, and embedded whitespace. Rejected URLs are dropped silently from
# the parser's perspective (a warning is printed to stderr); the resolver
# falls back to the `<their-store-remote>` placeholder so the directed
# error stays useful without rendering the attacker-controlled string.
_parse_secrets_store_file() {
local file="$1"
local line
while IFS= read -r line || [ -n "$line" ]; do
# Strip CRLF
line="${line%$'\r'}"
# Trim leading whitespace
line="${line#"${line%%[![:space:]]*}"}"
# Trim trailing whitespace
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
case "$line" in '#'*) continue ;; esac
# Split into spec (first whitespace-separated token) and url (rest of
# the line, verbatim). `read -r` does NOT glob-expand and preserves the
# tail in $rest as a single string — important so `work *` doesn't
# silently expand to `work file1 file2 ...`.
local spec="" rest=""
read -r spec rest <<< "$line"
local url="$rest"
if [ -n "$url" ]; then
# Reject URLs containing characters that could weaponize a
# copy-paste shell command, terminal escapes, or be ambiguous.
# Match order: control chars (incl. ESC \x1b), shell meta, whitespace.
case "$url" in
*[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*" "*)
echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2
url=""
;;
esac
fi
printf '%s\t%s\n' "$spec" "$url"
return 0
done < "$file"
return 1
}
# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never
# walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp),
# the walk does not run. Symlinks are resolved with `cd -P`.
# On success: prints "<expanded-store-dir>\t<source-file-path>\t<remote-url>"
# (URL empty if .secrets-store didn't include one) and returns 0. The caller
# (resolve_store) splits the tab-separated 3-tuple. We can't set a
# parent-shell variable from here because we're typically called inside
# `$(...)` command substitution, which runs in a subshell.
_find_secrets_store_file() {
local dir
dir=$(pwd -P 2>/dev/null) || dir="$PWD"
local home_resolved
home_resolved=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_resolved="$HOME"
while [ -n "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "$home_resolved" ]; do
# Bound: only walk while we're strictly below $HOME.
case "$dir" in
"$home_resolved"/*) ;;
*) return 1 ;;
esac
# F2: never follow a symlinked .secrets-store. A committed symlink
# could point at any user-readable file (~/.aws/credentials, /etc/passwd)
# and trick the resolver into reading attacker-chosen content.
if [ -L "$dir/.secrets-store" ]; then
:
elif [ -f "$dir/.secrets-store" ]; then
local parsed
if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then
# parsed is "<spec>\t<url>" (URL may be empty)
local spec="${parsed%%$'\t'*}"
local url="${parsed#*$'\t'}"
local expanded
expanded=$(_expand_store_path "$spec")
printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
return 0
fi
fi
dir=$(dirname "$dir")
done
return 1
}
# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
# Sets STORE_SOURCE to one of:
# "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store
# (empty when not present). check_initialized uses _REMOTE_URL to fill in a
# copy-paste-ready `git clone` command for teammates whose store doesn't
# exist yet.
resolve_store() {
local resolved=""
local source=""
_REMOTE_URL=""
if [ -n "${STORE_OVERRIDE:-}" ]; then
resolved=$(_expand_store_path "$STORE_OVERRIDE")
source="--store flag"
_LAST_FOUND_AT=""
elif _find_result=$(_find_secrets_store_file); then
# _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
# IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
# save/restore needed. URL field is empty when .secrets-store didn't
# include a URL or when it was rejected as unsafe.
IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result"
source=".secrets-store file ($_LAST_FOUND_AT)"
elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
resolved="$_USER_SECRETS_DIR"
source="SECRETS_DIR env var"
_LAST_FOUND_AT=""
else
resolved="$HOME/.secrets"
source="default"
_LAST_FOUND_AT=""
fi
SECRETS_DIR="$resolved"
KEY_FILE="$SECRETS_DIR/key.txt"
STORE_SOURCE="$source"
}
# Echo "==> Store: ..." when SECRETS_DIR is not the canonical default.
# Called from cmd_push/cmd_pull (and their workspace variants) after resolution.
echo_store_if_non_default() {
if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
info "Store: $SECRETS_DIR (from $STORE_SOURCE)"
fi
}
# ─── End store resolution ──────────────────────────────────────────────
# Collect secret files from a directory:
# .env, .env.*, .dev.vars (excluding .envrc, .environment-*)
# Sets the COLLECTED_FILES array. Returns 1 if no files found.
collect_env_files() {
local dir="$1"
COLLECTED_FILES=()
for f in "$dir"/.env "$dir"/.env.* "$dir"/.dev.vars; do
[ -f "$f" ] || continue
local basename_f
basename_f=$(basename "$f")
case "$basename_f" in
.envrc|.environment*) continue ;;
esac
COLLECTED_FILES+=("$f")
done
[ ${#COLLECTED_FILES[@]} -gt 0 ]
}
# ─── External files (.secrets-files) — EGB-531 ─────────────────────────
#
# A committed, project-local `.secrets-files` manifest declares files that
# live OUTSIDE the project root (e.g. global Gradle properties) whose
# *designated keys* should sync. One entry per non-empty non-comment line:
#
# gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
# <type> <path> <key> [key...]
#
# push extracts only the named keys → encrypts a subset blob under
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
# preserving every unrelated line. 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 <name>.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 "<type>\t<path>\t<keys>" per valid entry on
# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects
# shell/control chars and `..` in the path, restricts keys to a safe
# charset. No shell expansion is applied to file content.
_parse_secrets_files_manifest() {
local file="$1"
local line lineno=0 mtype mpath mkeys
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
line="${line%$'\r'}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[ -z "$line" ] && continue
case "$line" in '#'*) continue ;; esac
# `read -r` does not glob-expand and keeps the key list intact in $mkeys.
read -r mtype mpath mkeys <<< "$line"
case "$mtype" in
gradle-properties)
if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
echo "WARNING: $file line $lineno: expected 'gradle-properties <path> <key> [key...]'. Skipping." >&2
continue
fi
;;
file)
if [ -z "$mpath" ]; then
echo "WARNING: $file line $lineno: expected 'file <path>'. 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
v=$(head -1 "$f" 2>/dev/null | tr -dc '0-9')
[ "$v" = "2" ] && { echo 2; return; }
fi
echo 1
}
# The on-disk blob suffix for an external entry, format-aware. v2 unifies
# the legacy `gradle-properties` suffix to `properties` (matching the JSON
# manifest `type`); `file` is unchanged in both formats. The slug + this
# suffix + `.age` is the external blob name. This is the single source of
# truth for the suffix — push, pull, verify all route through it so a v1
# and a v2 store can never disagree on where a blob lives.
_external_blob_suffix() {
local mtype="$1"
if [ "$mtype" = "gradle-properties" ] && [ "$(_store_format)" = "2" ]; then
echo "properties"
else
echo "$mtype"
fi
}
# Merge managed key=value lines (from $2) into target file $1, preserving
# all unrelated lines/comments/order. Updates a managed key in place (first
# occurrence), collapses duplicates, appends new keys. Atomic + mode-safe.
merge_gradle_keys() {
local target="$1" kvfile="$2"
local -a mkey=() mval=() seen=()
local line key val i
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue
key="${line%%=*}"
val="${line#*=}"
mkey+=("$key"); mval+=("$val"); seen+=(0)
done < "$kvfile"
local n=${#mkey[@]}
[ "$n" -gt 0 ] || return 0
local dir; dir=$(dirname "$target")
mkdir -p "$dir"
local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1
local cont=0 t tkey matched
if [ -f "$target" ]; then
while IFS= read -r line || [ -n "$line" ]; do
local stripped="${line%$'\r'}"
if [ "$cont" -eq 1 ]; then
printf '%s\n' "$line" >> "$tmp"
_trailing_bs_odd "$stripped" && cont=1 || cont=0
continue
fi
_trailing_bs_odd "$stripped" && cont=1 || cont=0
t="${stripped#"${stripped%%[![:space:]]*}"}"
case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac
tkey="${t%%[=:[:space:]]*}"
matched=-1
for ((i = 0; i < n; i++)); do
if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi
done
if [ "$matched" -ge 0 ]; then
if [ "${seen[$matched]}" -eq 0 ]; then
printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp"
seen[$matched]=1
fi
else
printf '%s\n' "$line" >> "$tmp"
fi
done < "$target"
fi
for ((i = 0; i < n; i++)); do
if [ "${seen[$i]}" -eq 0 ]; then
printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp"
fi
done
# Back up the existing target before overwriting (seatbelt for the merge
# engine touching a hand-maintained file), preserve mode, atomic rename.
if [ -f "$target" ]; then
cp "$target" "$target.secrets-bak" 2>/dev/null || true
local mode
mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600)
chmod "$mode" "$tmp" 2>/dev/null || true
else
chmod 600 "$tmp" 2>/dev/null || true
fi
# TOCTOU recheck before the write into $HOME.
if [ -L "$target" ]; then
rm -f "$tmp"
echo "ERROR: $target became a symlink; aborting merge." >&2
return 1
fi
mv "$tmp" "$target"
}
# Encrypt the managed keys declared in <root>/.secrets-files into
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() {
local root="$1" project="$2" pubkey="$3"
# 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")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded"
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")
age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp"
rm -f "$tmp"
info "Extracted $found key(s) from $mpath"
pushed=$((pushed + 1))
done <<< "$entries"
[ "$pushed" -gt 0 ]
}
# Decrypt the external blobs named by <root>/.secrets-files and merge their
# keys into the declared targets. Skips (with a warning) unsafe targets or
# missing blobs rather than aborting the whole pull.
pull_external_files() {
local root="$1" project="$2"
# .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="$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$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
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 <path>` — 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 <project-relative-path>"
# 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 "<type>\t<path>\t<keys>" 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")
}
# 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 [ -f "$json" ] && [ ! -L "$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 [ -e "$legacy" ]; then
if [ -L "$legacy" ]; then
echo "WARNING: $legacy is a symlink; ignoring." >&2
elif [ -f "$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
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 [ -f "$json" ] && [ ! -L "$json" ]; then
if [ -f "$legacy" ] && [ ! -L "$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
[ -e "$legacy" ] || return 0
if [ -L "$legacy" ]; then
echo "WARNING: $legacy is a symlink; ignoring." >&2
return 0
fi
[ -f "$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 <root>. Emits nothing (and never dies) when <root> 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
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 clone their existing secrets repo, not init a fresh one.
# Catch it BEFORE git init so we don't leave a half-initialized store.
if [ -f "$KEY_FILE" ]; then
# Render a runnable clone command when .secrets-store carried a remote
# URL (already sanitized by resolve_store), mirroring check_initialized.
local clone_src="<your-secrets-remote>"
[ -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' — clone your existing secrets repo instead:
git clone $clone_src $SECRETS_DIR
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 — it has no
# v1 blobs, so it is already in v2 shape. The marker is a committed,
# non-secret metadata file (NOT gitignored); the first push stages it.
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)
info "Done! Your public key is:"
echo " $pubkey"
echo ""
echo "Next steps:"
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
echo " 3. Run 'secrets push <project>' from a project directory"
}
# Encrypt env files from a source dir into a project path in the secrets repo.
# Does NOT commit or push — caller handles that.
push_dir_to_project() {
local source_dir="$1"
local project="$2"
local pubkey="$3"
if ! collect_env_files "$source_dir"; then
return 1
fi
info "$project: ${#COLLECTED_FILES[@]} file(s)"
for f in "${COLLECTED_FILES[@]}"; do
echo " $(basename "$f")"
done
mkdir -p "$SECRETS_DIR/$project"
for f in "${COLLECTED_FILES[@]}"; do
local name
name=$(basename "$f")
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
done
return 0
}
# Git commit + push for the secrets repo. Shared by push and push --workspaces.
commit_and_push_secrets() {
local message="$1"
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then
die "Fast-forward pull failed. Run 'secrets pull' first, then retry push."
fi
fi
# 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
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 <project>/<name>.age,
# nested entries at <project>/<relpath>.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
local pubkey
pubkey=$(get_pubkey)
# ── 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 -r "$pubkey" -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" "$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
# ── 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
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \
'{version: '"$MANIFEST_VERSION"', dotenv: $add} | 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
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
# ── 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
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"
if [ ! -s "$target_dir/$rel" ]; then
echo "WARNING: Decrypted file '$rel' is empty (possibly 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"
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 / 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"
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 / store .gitignore if missing
ensure_store_protections
}
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:"
# Recurse the whole project tree so nested manifest blobs
# (<project>/<relpath>.age) are visible, not just top-level entries.
# External blobs (external/<slug>.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 <project>' to add one."
fi
# DX-7 hint: when a non-default store is active, point users at `secrets which`.
if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
echo ""
info "Showing projects in $SECRETS_DIR. Run 'secrets which' for details."
fi
}
cmd_rm() {
check_cmd git
resolve_store
check_initialized
local project="${1:-}"
[ -n "$project" ] || die "Usage: secrets rm <project>"
if [ ! -d "$SECRETS_DIR/$project" ]; then
die "Project '$project' not found. Run: secrets list"
fi
info "Removing project: $project"
git -C "$SECRETS_DIR" rm -r "$project/" >/dev/null
git -C "$SECRETS_DIR" commit -m "remove $project" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" push >/dev/null 2>&1
info "Removed $project from remote"
else
info "Removed $project locally (no remote configured)"
fi
}
cmd_rekey() {
check_cmd age
check_cmd git
resolve_store
check_initialized
check_key
# Create temp dir with cleanup trap
local tmpdir
tmpdir=$(mktemp -d)
# `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the
# function returns (the local would be out of scope). Normal completion
# cleans up explicitly and clears the trap at the end of this function.
trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM
info "Decrypting all files with current key..."
# Decrypt all .age files into temp dir
local file_count=0
for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue
local project
project=$(basename "$dir")
[[ "$project" == .* ]] && continue
mkdir -p "$tmpdir/$project"
# Walk the WHOLE project tree, not just its top level. Manifest dotenv
# entries can nest (<project>/<relpath>.age) and external blobs live in
# <project>/external/<slug>.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
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
git -C "$SECRETS_DIR" push >/dev/null 2>&1
info "Pushed rekeyed secrets to remote"
else
info "Committed rekeyed secrets locally (no remote configured)"
fi
info "Rekey complete!"
echo ""
echo "IMPORTANT: Copy new key to your other machine:"
echo " scp $KEY_FILE <other-machine>:$KEY_FILE"
echo ""
echo "WARNING: Old ciphertext remains in git history."
echo "For full rotation, create a fresh repo."
# Clean up the plaintext temp dir while $tmpdir is still in scope, then
# drop the trap so it can't fire (and error under set -u) post-return.
rm -rf "$tmpdir"
trap - EXIT INT TERM
}
cmd_clear() {
local dir="$PWD"
if ! collect_env_files "$dir"; then
info "No secret files to clear in $dir"
return
fi
local count=0
for f in "${COLLECTED_FILES[@]}"; do
rm "$f"
count=$((count + 1))
done
info "Cleared $count secret file(s) from $dir"
}
cmd_clear_workspaces() {
check_cmd jq
local root="$PWD"
local total=0
# Clear root
if collect_env_files "$root"; then
for f in "${COLLECTED_FILES[@]}"; do
rm "$f"
total=$((total + 1))
done
fi
# Clear each workspace
local workspaces
workspaces=$(get_workspaces "$root")
while IFS= read -r ws; do
[ -n "$ws" ] || continue
local ws_dir="$root/$ws"
if collect_env_files "$ws_dir"; then
for f in "${COLLECTED_FILES[@]}"; do
rm "$f"
total=$((total + 1))
done
fi
done <<< "$workspaces"
info "Cleared $total secret file(s) from workspace"
}
# EXIT-trap helpers for cmd_run. Defined as functions (not inline string
# traps) so $_RUN_PWD is dereferenced safely regardless of special chars
# in the path. F1: trap "cd '$X'" breaks when X contains a single quote.
_run_cleanup() {
cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear
}
_run_cleanup_workspaces() {
cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear_workspaces
}
cmd_run() {
local workspace_mode=false
local project=""
# Parse flags before the command
while [ $# -gt 0 ]; do
case "$1" in
-w|--workspaces) workspace_mode=true; shift ;;
--) shift; break ;;
-*) die "Unknown flag: $1. Usage: secrets run [-w] [--] <command...>" ;;
*) break ;;
esac
done
[ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"
# F8: pin the project directory now so the EXIT trap clears the right
# plaintext files even if the user's command does `cd` into another dir.
# Use a global + named function (NOT string-interpolated trap) so paths
# with special characters — apostrophes, dollar signs, spaces — work.
# Single-quoting `$_RUN_PWD` into a string trap would break on any path
# with a single quote (e.g. /Users/bri/it's-app), and the trap would
# silently fail to clean up plaintext secrets. EGB-281 F1.
_RUN_PWD="$PWD"
# Pull secrets (cmd_pull/cmd_pull_workspaces call resolve_store internally)
if [ "$workspace_mode" = true ]; then
cmd_pull_workspaces
else
cmd_pull "$project"
fi
if [ "$workspace_mode" = true ]; then
trap _run_cleanup_workspaces EXIT
else
trap _run_cleanup EXIT
fi
# Execute the command, capturing exit code (don't let set -e kill us)
local rc=0
"$@" || rc=$?
exit "$rc"
}
cmd_which() {
resolve_store
echo "store: $SECRETS_DIR"
echo "source: $STORE_SOURCE"
# EGB-700: surface the store format so users can tell v1 from v2 during the
# migration window. A v1 store is a legacy store with no format marker.
echo "format: v$(_store_format)"
# v2 manifest (.secrets.json): validate and summarize. Validation here
# is deliberately fatal (symlink / malformed / unsupported version) so
# `secrets which` doubles as the manifest linter.
local json_manifest="$PWD/$SECRETS_JSON_NAME"
if [ -e "$json_manifest" ]; then
_check_manifest_file "$json_manifest"
echo "manifest ($SECRETS_JSON_NAME at $json_manifest):"
local entry
while IFS= read -r entry; do
[ -n "$entry" ] || continue
if _validate_dotenv_rel_path "$entry"; then
echo " dotenv $entry"
else
echo " dotenv $entry [UNSAFE — will be refused]"
fi
done < <(jq -r '.dotenv // [] | .[]' "$json_manifest")
local etype epath ekeys
while IFS=$'\t' read -r etype epath ekeys; do
[ -n "$etype" ] || continue
echo " $etype $epath $ekeys"
done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$json_manifest")
fi
# 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
}
# Decrypt-test one blob with the current key. Plaintext is streamed to
# /dev/null and never written to disk (read-only contract). Returns 0 if the
# blob decrypts, non-zero otherwise.
_verify_blob_decrypts() {
age -d -i "$KEY_FILE" "$1" >/dev/null 2>&1
}
# `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob
# in every project. No manifest consistency check: the store carries only
# ciphertext (manifests live in each project's repo), so orphan/missing
# detection is impossible store-wide. This is the migration integrity gate.
_verify_all() {
local checked=0 failed=0 dir project f rel
for dir in "$SECRETS_DIR"/*/; do
[ -d "$dir" ] || continue
project=$(basename "$dir")
[[ "$project" == .* ]] && continue
# Recurse the whole project tree (top-level / nested / external) — never a
# non-recursive glob, or nested blobs would be silently skipped (the exact
# class of bug that orphaned nested blobs on rekey before v0.4.0.0).
while IFS= read -r f; do
[ -f "$f" ] || continue
checked=$((checked + 1))
if ! _verify_blob_decrypts "$f"; then
rel="${f#"$SECRETS_DIR"/}"
echo "FAIL: $rel does not decrypt with the current key." >&2
failed=$((failed + 1))
fi
done < <(find "$dir" -type f -name '*.age')
done
if [ "$checked" -eq 0 ]; then
info "verify --all: store is empty — nothing to check."
return 0
fi
if [ "$failed" -gt 0 ]; then
echo "verify --all: $failed of $checked blob(s) failed to decrypt." >&2
return 1
fi
info "verify --all: OK — all $checked blob(s) decrypt with the current key (integrity only; run 'secrets verify' in a project for manifest consistency)."
}
# `secrets verify [project]` — current-project consistency + decrypt check.
# Cross-checks the committed .secrets.json against the store both ways
# (declared-but-missing blobs, orphaned blobs) and decrypt-tests every blob.
_verify_project() {
local explicit_project="$1"
local manifest="$PWD/$SECRETS_JSON_NAME"
if [ ! -e "$manifest" ]; then
die "No $SECRETS_JSON_NAME in $PWD.
'secrets verify' checks a project's manifest against the store. Either cd
into a project that has a $SECRETS_JSON_NAME, or run 'secrets verify --all'
for a store-wide decrypt sweep."
fi
_check_manifest_file "$manifest" # fatal on symlink / malformed / version
local project
project=$(derive_project_name "$explicit_project")
local pdir="$SECRETS_DIR/$project"
local findings=0 checked=0
# `expected` accumulates the store-relative blob paths the manifest implies,
# newline-framed (leading + trailing \n per entry) so the orphan walk can
# test membership. bash 3.2 has no associative arrays — this string-set +
# case match mirrors the `seen` pattern in _external_entries_for_push.
local expected=$'\n'
# ── dotenv entries: rail + missing-blob + decrypt ──
local rel blob
while IFS= read -r rel; do
[ -n "$rel" ] || continue
if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then
echo "FINDING: unsafe dotenv path in $SECRETS_JSON_NAME: '$rel' (will be refused)." >&2
findings=$((findings + 1))
# Still account for it so a matching stray blob isn't ALSO flagged as an
# orphan (one bad entry → one finding, not two).
expected="$expected$rel.age"$'\n'
continue
fi
expected="$expected$rel.age"$'\n'
blob="$pdir/$rel.age"
if [ ! -f "$blob" ]; then
echo "FINDING: '$rel' is declared in $SECRETS_JSON_NAME but has no blob in the store ($project/$rel.age missing). Run 'secrets push'." >&2
findings=$((findings + 1))
continue
fi
checked=$((checked + 1))
if ! _verify_blob_decrypts "$blob"; then
echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2
findings=$((findings + 1))
fi
done < <(jq -r '.dotenv // [] | .[]' "$manifest")
# ── external entries: missing-blob + decrypt ──
# verify only needs type + path to locate the blob; keys are irrelevant here.
local etype epath slug erel eblob
while IFS=$'\t' read -r etype epath _; do
[ -n "$etype" ] || continue
slug=$(_secrets_files_slug "$epath")
erel="external/$slug.$(_external_blob_suffix "$etype").age"
expected="$expected$erel"$'\n'
eblob="$pdir/$erel"
if [ ! -f "$eblob" ]; then
echo "FINDING: external '$epath' ($etype) is declared but has no blob in the store ($project/$erel missing). Run 'secrets push'." >&2
findings=$((findings + 1))
continue
fi
checked=$((checked + 1))
if ! _verify_blob_decrypts "$eblob"; then
echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2
findings=$((findings + 1))
fi
done < <(_json_external_entries "$manifest")
# ── orphan detection: any stored blob the manifest doesn't account for ──
if [ -d "$pdir" ]; then
local f frel
while IFS= read -r f; do
[ -f "$f" ] || continue
frel="${f#"$pdir"/}"
case "$expected" in
*$'\n'"$frel"$'\n'*) : ;; # accounted for by a manifest entry
*)
echo "FINDING: orphan blob $project/$frel has no entry in $SECRETS_JSON_NAME." >&2
findings=$((findings + 1))
;;
esac
done < <(find "$pdir" -type f -name '*.age')
fi
if [ "$findings" -gt 0 ]; then
echo "verify: $findings finding(s) for project '$project'." >&2
return 1
fi
info "verify: OK — $checked blob(s) verified for '$project' (manifest and store agree; all decrypt)."
}
# `secrets verify [--all] [project]` — read-only integrity check. Exits
# non-zero on any finding so it can gate `migrate --finalize` and CI.
cmd_verify() {
resolve_store
check_initialized
check_key
local all=false explicit_project=""
while [ $# -gt 0 ]; do
case "$1" in
--all) all=true; shift ;;
-*) die "Unknown verify flag: $1. Usage: secrets verify [--all] [project]" ;;
*) explicit_project="$1"; shift ;;
esac
done
if [ "$all" = true ]; then
_verify_all
else
_verify_project "$explicit_project"
fi
}
# ─── Store-format-v2 migration (EGB-703) ──────────────────────────────
#
# v2 renames the legacy `properties` blob suffix (.gradle-properties.age →
# .properties.age) and marks the store self-describing via .secrets-format.
# Migration is copy-forward and non-destructive until --finalize:
# secrets migrate --dry-run # per project: report old→new, write nothing
# secrets migrate # per project: write v2 twins beside v1 blobs
# secrets migrate --finalize # store-wide: verify, drop v1, stamp v2
# Per-project (needs the project manifest to know which externals are
# `properties`); finalize is store-wide. Mirrors verify's project/--all split.
# Copy-forward (or dry-run preview) for the current project. Reads
# $PWD/.secrets.json; only `properties` external blobs rename in v2.
_migrate_project() {
local dry_run="$1"
if [ "$(_store_format)" = "2" ]; then
info "Store is already format v2 — nothing to migrate."
return 0
fi
local manifest="$PWD/$SECRETS_JSON_NAME"
if [ ! -e "$manifest" ]; then
die "No $SECRETS_JSON_NAME in $PWD.
'secrets migrate' copy-forwards a project's v1 blobs to their v2 names and
reads the project manifest to do so. cd into a project that has a manifest,
then run 'secrets migrate'. (Store-wide 'secrets migrate --finalize' comes
after every project is migrated.)"
fi
_check_manifest_file "$manifest"
local project; project=$(derive_project_name "")
local pdir="$SECRETS_DIR/$project"
local moved=0 already=0 would=0 etype epath slug old new
while IFS=$'\t' read -r etype epath _; do
[ -n "$etype" ] || continue
# Only `properties` blobs change name in v2; dotenv and `file` are already
# in their v2 shape and never move.
[ "$etype" = "gradle-properties" ] || continue
slug=$(_secrets_files_slug "$epath")
old="$pdir/external/$slug.gradle-properties.age"
new="$pdir/external/$slug.properties.age"
[ -f "$old" ] || continue # nothing pushed yet (or already dropped)
if [ -f "$new" ]; then # idempotent: twin already exists
already=$((already + 1))
continue
fi
if [ "$dry_run" = true ]; then
echo "would migrate: $project/external/$slug.gradle-properties.age -> $slug.properties.age"
would=$((would + 1))
else
cp "$old" "$new"
moved=$((moved + 1))
fi
done < <(_json_external_entries "$manifest")
if [ "$dry_run" = true ]; then
echo "migrate --dry-run: $would blob(s) would be copy-forwarded for '$project' (writes nothing); $already already present. v1 blobs are kept until 'secrets migrate --finalize'."
return 0
fi
if [ "$moved" -eq 0 ] && [ "$already" -eq 0 ]; then
info "Nothing to migrate for '$project' (no v1 properties blobs)."
return 0
fi
ensure_store_protections
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "migrate: copy-forward v2 twins for $project" >/dev/null 2>&1 || true
info "Copy-forward for '$project': $moved new v2 twin(s), $already already present. v1 blobs kept (non-destructive). Run 'secrets migrate --finalize' once every project is migrated and every machine is upgraded."
}
# Store-wide finalize: the only destructive step. Refuses unless verify --all
# is green and every v1 properties blob has a v2 twin. Cuts a recovery tag,
# stamps the marker, then drops v1 blobs.
_migrate_finalize() {
local force="$1"
check_key
if [ "$(_store_format)" = "2" ]; then
info "Store is already format v2 — nothing to finalize."
return 0
fi
# (gate 1) every blob must decrypt with the current key.
info "Verifying every blob decrypts before finalizing..."
if ! _verify_all >/dev/null 2>&1; then
die "Refusing to finalize: 'secrets verify --all' is not green — a blob does not decrypt. Run 'secrets verify --all' to see which, fix it, then re-run --finalize."
fi
# (gate 2) every v1 properties blob must have a v2 twin (project migrated).
local untwinned="" f new v1count=0
while IFS= read -r f; do
[ -f "$f" ] || continue
v1count=$((v1count + 1))
new="${f%.gradle-properties.age}.properties.age"
[ -f "$new" ] || untwinned="$untwinned ${f#"$SECRETS_DIR"/}"$'\n'
done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age')
if [ -n "$untwinned" ]; then
die "Refusing to finalize: these v1 properties blobs have no v2 twin (their project was not migrated):
$untwinned cd into each project and run 'secrets migrate', then re-run 'secrets migrate --finalize'."
fi
if [ "$v1count" -eq 0 ]; then
# No v1 blobs at all — just stamp the marker (dotenv/file-only store).
printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
ensure_store_protections
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2" >/dev/null 2>&1 || true
git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true
info "Store finalized to format v2 (no v1 blobs to drop)."
return 0
fi
# (gate 3) operator confirms every machine is upgraded.
if [ "$force" != true ]; then
echo "Finalize will drop $v1count v1 blob(s) and stamp the store format v2."
echo "Any machine still running a pre-v2 'secrets' will stop seeing 'properties'"
echo "externals from this store until it upgrades (git pull in the secrets repo)."
printf "Confirm every machine is upgraded? Type 'yes': "
local reply=""
read -r reply < /dev/tty 2>/dev/null || read -r reply || true
[ "$reply" = "yes" ] || die "Finalize aborted — no confirmation."
fi
# Recovery tag BEFORE any mutation: points at the pre-finalize commit (v1
# blobs intact, no marker), so `git checkout <tag>` fully restores v1.
local tag="pre-v2-migrate-$(git -C "$SECRETS_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
git -C "$SECRETS_DIR" tag "$tag" >/dev/null 2>&1 || true
# Stamp the marker FIRST, then drop v1 blobs. If finalize crashes between
# the two, the store reads as v2 and the (verified) v2 twins serve every
# upgraded client; leftover v1 blobs are harmless orphans a re-run cleans.
printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
while IFS= read -r f; do
[ -f "$f" ] || continue
rm -f "$f"
done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age')
ensure_store_protections
git -C "$SECRETS_DIR" add -A
git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2 (drop $v1count v1 blob(s))" >/dev/null 2>&1 || true
git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true
info "Store finalized to format v2. Dropped $v1count v1 blob(s). Recovery tag in the store: $tag"
}
cmd_migrate() {
resolve_store
check_initialized
local dry_run=false finalize=false force=false
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true; shift ;;
--finalize) finalize=true; shift ;;
--yes|--force) force=true; shift ;;
-*) die "Unknown migrate flag: $1. Usage: secrets migrate [--dry-run | --finalize] [--yes]" ;;
*) die "migrate takes no project argument. Run it from inside a project (copy-forward) or use --finalize (store-wide)." ;;
esac
done
if [ "$finalize" = true ]; then
_migrate_finalize "$force"
else
_migrate_project "$dry_run"
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 --frozen Sync only manifest-declared files (skip auto-add)
secrets push --dry-run Show what would be added/synced; change nothing
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 add <path> Declare a project-relative file in .secrets.json
secrets clear Remove plaintext secret files from current directory
secrets clear -w|--workspaces Clear secrets from all workspaces in package.json
secrets run [-w] <command> Pull secrets, run command, clear secrets on exit
secrets list List all projects and their secret files
secrets rm <project> Remove a project's secrets from the repo
secrets rekey Re-encrypt all secrets with a new key
secrets verify [project] Check the manifest against the store + decrypt every blob
secrets verify --all Decrypt-test every blob in every project (integrity gate)
secrets migrate [--dry-run] Copy-forward this project's blobs to store format v2
secrets migrate --finalize Drop v1 blobs and mark the store v2 (after verify)
secrets which Show the active store, manifest, and external entries
secrets where Alias for `which`
secrets status Alias for `which`
Tracked files: .env, .env.*, .dev.vars
Manifest (.secrets.json):
A committed project-root manifest declaring everything the project
syncs (requires jq). `secrets push` discovers conventional files and
auto-adds them with a notice; set {"options":{"autoAdd":false}} to
require explicit `secrets add` instead. Dotenv paths are project-
relative (nested workspace paths welcome); external entries use
{"type":"properties"|"file","path":...,"keys":[...]}:
{
"version": 2,
"options": { "autoAdd": true },
"dotenv": [".env", "packages/web/.env.development"],
"external": [
{ "type": "properties", "path": "~/.gradle/gradle.properties",
"keys": ["beaconClerkPkTest"] },
{ "type": "file", "path": "~/keystores/upload.keystore" }
]
}
A legacy .secrets-files is absorbed into .secrets.json on the next
push (gradle-properties entries become type "properties") and can be
deleted afterwards. Without jq, manifest-less projects keep working;
manifest features are skipped with a notice.
If [project] is omitted, it is derived from the current directory's
git remote (if available) or the directory name.
Stores:
Most users have one ~/.secrets/ store. To use a separate store (e.g.
for work secrets vs personal), use any of these resolution rules
(highest precedence first):
1. --store <dir> flag secrets --store ~/.secrets-work pull
2. .secrets-store file in project echo work > .secrets-store && git add ...
3. SECRETS_DIR env var (legacy) SECRETS_DIR=~/.secrets-work secrets pull
4. ~/.secrets default
Bare names ("work") expand to ~/.secrets-work. The name "default"
resolves to ~/.secrets. Run `secrets which` to inspect the active store.
Optional .secrets-store URL hint:
A second whitespace-separated token on the line is treated as the
store's git remote URL. It is used to fill in a runnable `git clone
<url> <path>` in the missing-store error so teammates joining the
project don't have to ask for the URL. Example:
work git@github.com:acme/work-secrets.git
Workspaces:
With -w/--workspaces, reads package.json "workspaces" field to find
workspace directories. Each workspace's secret files are stored under
<monorepo>/<workspace-path>/ in the secrets repo. Root secret files
are stored under <monorepo>/ directly. Requires jq.
External files (.secrets-files):
Sync files (or specific keys from files) OUTSIDE the project root.
Create a committed .secrets-files in the project root, one entry per
line:
# <type> <path> <keys...>
gradle-properties ~/.gradle/gradle.properties beaconClerkPkTest beaconClerkPkLive
file ~/keystores/beacon-upload.keystore
gradle-properties: on push, the named keys are extracted from <path>
and encrypted under <project>/external/ in the store. On pull, they
are MERGED back into <path>, leaving all unrelated keys untouched.
The target basename must be 'gradle.properties'.
file: the whole file is encrypted verbatim (binary-safe — keystores,
certificates). On pull it is restored with mode 600; an existing
divergent target is backed up to <name>.secrets-bak first. No keys.
All targets must resolve inside $HOME (no '..', no symlinks). Run
'secrets which' from the project to confirm the manifest parsed.
Note: pulled external targets are permanent plaintext on disk —
'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
shift
cmd_push "$@"
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 "$@"
;;
add) cmd_add "${2:-}" ;;
list) cmd_list ;;
rm) cmd_rm "${2:-}" ;;
rekey) cmd_rekey ;;
verify) shift; cmd_verify "$@" ;;
migrate) shift; cmd_migrate "$@" ;;
which|where|status) cmd_which ;;
help|--help|-h) cmd_help ;;
*) die "Unknown command: $1. Run 'secrets help' for usage." ;;
esac