Merge pull request 'v0.7.3.0 feat: secrets join + verified onboarding scripts (EGB-671)' (#11) from brian/egb-671-create-real-install-scripts into main

This commit is contained in:
EGBT Technologies 2026-06-09 01:30:08 +02:00
commit 57f1280262
9 changed files with 669 additions and 84 deletions

View file

@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme. and this project adheres to a four-digit MAJOR.MINOR.PATCH.MICRO version scheme.
## [0.7.3.0] - 2026-06-08
### Added
- **Real install / onboarding scripts (EGB-671)** — onboarding a machine is now
(close to) one command, and a mis-copied key fails loudly instead of silently.
- **`secrets join --remote <url> --key <path>`** — second-machine onboarding in
one verb: clones the vault, installs the key at mode 600, and **verifies the
key actually decrypts the store before declaring success**. An empty vault
reports "nothing to verify yet" (it never prints a false `VERIFIED`); a wrong
key fails loudly with the store left in place to fix. All security logic
(store resolution, URL handling, path rails) is reused from the audited core,
not re-implemented in a side script.
- **`secrets init --remote <url>`** — wires the remote and pushes the initial
store so the upstream branch exists, so your first project `push` doesn't trip
the fast-forward-pull guard on a brand-new empty remote. Run interactively,
`init` also offers to add your first project's secrets (default No, skipped
under `--yes` / non-interactive, so it stays a clean primitive for CI).
- **`install.sh`** — thin bootstrap that ships in the repo: checks `age` + `jq`
+ `git`, then prints the `PATH` line, the onboarding next-steps, the upgrade
one-liner, and a key-transfer hint. It never edits your shell config and never
runs `sudo` (it prints the command so you stay in control).
- **First-manifest `options.autoAdd` prompt (EGB-677 contract #2)** — the first
`push` that scaffolds a project's manifest now records an explicit, committed
`options.autoAdd` value (asked once when interactive; the default ON, written
explicitly, under automation).
### Fixed
- **Day-2 silent decrypt failure**`secrets pull` now dies loudly when a blob
fails to decrypt with the current key (all three decrypt paths), instead of
emitting a warning and continuing with exit 0. A wrong key can no longer pass
unnoticed after onboarding.
- The `secrets init` second-machine trap now points at `secrets join` (the real
one-command path) instead of a manual `git clone`.
## [0.7.2.0] - 2026-06-08 ## [0.7.2.0] - 2026-06-08
### Added ### Added

122
README.md
View file

@ -58,83 +58,95 @@ Beyond project files, `secrets` can also sync files that live *outside* the proj
## Prerequisites ## Prerequisites
- **macOS** (uses Homebrew for installation) - **macOS or Linux**
- **git** (already installed on most Macs — type `git --version` to check) - **git** (`git --version` to check)
- **age** (the encryption tool — installed in step 1 below) - **age** and **jq**`install.sh` checks for these and prints the exact install command for your platform (Homebrew on macOS, `apt`/`dnf` on Linux)
## Setup ## Setup
### First machine (one-time setup) Clone the tool repo, then run `install.sh`. It checks dependencies and prints the
two commands to finish setup. It never edits your shell config and never runs
sudo — it prints the commands so you stay in control.
```bash ```bash
# 1. Install the encryption tool
brew install age
# 2. Download the secrets tool (this repo — contains only the CLI, no secret files)
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets
# 3. Make the 'secrets' command available everywhere ./install.sh
# Add this line to your shell config file (~/.zshrc on Mac):
export PATH="$HOME/dev/secrets:$PATH"
# Then restart your terminal, or run:
source ~/.zshrc
# 4. Initialize your encrypted secrets store
# This creates a folder at ~/.secrets/ with your encryption key
secrets init
# 5. Create a PRIVATE repository on GitHub to store your encrypted secrets
# Go to github.com/new, name it something like 'my-secrets', and make sure
# "Private" is selected. Then connect it:
cd ~/.secrets
git remote add origin git@github.com:<you>/my-secrets.git
git push -u origin main
``` ```
> **Important:** Step 5 creates a *separate* private repo for your encrypted secrets. This is different from the `secrets` tool repo you cloned in step 2. The tool repo can be public — it contains no secrets. The `~/.secrets/` repo must be private. `install.sh` prints a `export PATH="$HOME/dev/secrets:$PATH"` line — add it to your
shell config (`~/.zshrc` or `~/.bashrc`) and restart your terminal. Then onboard
this machine with one of the two flows below.
### Additional machines ### First machine (new vault)
On each new machine (your desktop, a teammate's laptop, etc.):
```bash ```bash
# 1. Install prerequisites and the tool (same as steps 1-3 above) # 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a
brew install age # Codeberg/GitLab private repo). It holds only ciphertext — never your key.
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets # Then wire it up and push the store in one command:
export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc secrets init --remote git@github.com:<you>/my-secrets.git
# 2. Clone the encrypted secrets repo # 2. (optional) Add a project's secrets. From a project directory:
git clone git@github.com:<you>/my-secrets.git ~/.secrets
# 3. Copy the encryption key from your first machine
# This is the only step that requires direct machine-to-machine transfer.
# Choose one method:
#
# Option A: AirDrop (Mac to Mac)
# On your first machine, right-click ~/.secrets/key.txt → Share → AirDrop
# Save it to ~/.secrets/key.txt on the new machine
#
# Option B: Secure copy over SSH
# scp first-machine:~/.secrets/key.txt ~/.secrets/key.txt
#
# Option C: USB drive
# Copy key.txt to a USB drive, transfer it, delete from USB after
# 4. Pull your secrets into any project
cd ~/myapp cd ~/myapp
secrets pull secrets push
# The first push asks once whether to auto-track new env files and records
# your choice in the project's .secrets.json.
``` ```
`secrets init --remote` generates your key (`~/.secrets/key.txt`), wires the
remote, and pushes the initial store so the upstream branch exists. The private
secrets repo is separate from this tool repo — the tool repo is public and holds
no secrets; the `~/.secrets/` repo must be private.
> Running `secrets init` interactively (in a terminal) also offers to add your
> first project's secrets right away. Run it with `--yes` (or in any non-tty
> context like CI) to skip that prompt and just create the vault.
### Other machines (join an existing vault)
On a second machine, a desktop, or a teammate's laptop:
```bash
# 1. Clone the tool and run the bootstrap (as in Setup above)
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
cd ~/dev/secrets && ./install.sh # add the printed PATH line to your shell config
# 2. Get key.txt onto this machine (the one manual, out-of-band step):
# AirDrop (Mac→Mac), or
# scp first-machine:~/.secrets/key.txt ~/Downloads/key.txt, or
# a USB drive (delete from the drive afterward)
# 3. Join the vault in one command:
secrets join --remote git@github.com:<you>/my-secrets.git --key ~/Downloads/key.txt
```
`secrets join` clones the vault, installs the key at mode 600, and **verifies the
key actually decrypts the store before declaring success** — a mis-copied key
fails loudly here, not silently on a later `secrets pull`. On success it tells you
to run `secrets pull` in any project.
> **The key file (`~/.secrets/key.txt`) is the only thing that needs to be transferred manually.** It never leaves your machines — it's excluded from git, never uploaded, never transmitted over the internet. Anyone with this file can decrypt all your secrets, so treat it like a password. > **The key file (`~/.secrets/key.txt`) is the only thing that needs to be transferred manually.** It never leaves your machines — it's excluded from git, never uploaded, never transmitted over the internet. Anyone with this file can decrypt all your secrets, so treat it like a password.
### Sharing with teammates ### Sharing with teammates
To share secrets with a teammate, they need: To share secrets with a teammate, they need:
1. Access to your private `my-secrets` GitHub repo (add them as a collaborator) 1. Access to your private secrets repo (add them as a collaborator)
2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person) 2. A copy of `key.txt` (send it directly — AirDrop, USB, or in-person)
Everyone on the team uses the same key. When anyone runs `secrets push`, the encrypted files are updated and everyone else can `secrets pull` to get the latest version. Everyone on the team uses the same key. A teammate joins with
`secrets join --remote <repo-url> --key <path-to-key.txt>`. When anyone runs
`secrets push`, the encrypted files update and everyone else runs `secrets pull`
to get the latest.
### Updating the tool
```bash
git -C ~/dev/secrets pull
```
If your store was last written by a newer client than yours, `secrets` prints a
one-line version-skew nudge — that's your cue to run the command above.
## Usage ## Usage

View file

@ -1 +1 @@
0.7.2.0 0.7.3.0

113
install.sh Executable file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
#
# secrets — thin onboarding bootstrap (EGB-671).
#
# This script ships INSIDE the repo: you already cloned the repo to get it, so
# its only jobs are (1) verify the dependencies the tool needs and (2) print the
# exact commands to finish setup. It deliberately does NOT:
# - edit your shell rc files (it prints the PATH line for you to paste)
# - invoke sudo or install packages behind your back (it prints the command)
# - re-implement any of the tool's security logic
#
# This is a security tool whose whole pitch is "verify, don't trust" — so the
# installer holds itself to a higher bar than convenience, not a lower one.
#
# Usage:
# ./install.sh # check deps, print setup + next steps
# ./install.sh --help
set -euo pipefail
# Resolve the directory this script lives in (the cloned tool repo). Uses bash
# builtins only so it works under a minimal PATH.
_src="${BASH_SOURCE[0]}"
TOOL_DIR="$(cd "${_src%/*}" 2>/dev/null && pwd)"
usage() {
cat <<EOF
install.sh — finish setting up the 'secrets' tool.
Run this once after cloning the repo. It verifies dependencies (age, jq, git)
and prints the commands to put 'secrets' on your PATH and onboard a machine.
Usage:
./install.sh Check dependencies and print setup + next steps
./install.sh --help Show this help
It never edits your shell config and never runs sudo — it prints the exact
commands so you stay in control (this is a secrets tool, after all).
Onboarding after setup:
First machine: secrets init --remote <your-private-repo-url>
Other machine: secrets join --remote <your-private-repo-url> --key <key.txt>
EOF
}
# Print the install command for a package, using whatever package manager is
# present. For sudo-requiring managers we PRINT the line for you to run — the
# installer never escalates on its own.
install_hint() {
local pkg="$1"
if command -v brew >/dev/null 2>&1; then
echo "brew install $pkg"
elif command -v apt-get >/dev/null 2>&1; then
echo "sudo apt-get install -y $pkg"
elif command -v dnf >/dev/null 2>&1; then
echo "sudo dnf install -y $pkg"
else
echo "install '$pkg' with your system package manager"
fi
}
case "${1:-}" in
--help|-h) usage; exit 0 ;;
"") ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
echo "secrets — bootstrap check (tool dir: $TOOL_DIR)"
echo ""
# Dependency check. age + jq + git are all load-bearing on the cold-start path:
# jq became required once .secrets.json (manifest) is JSON, so it must be present
# BEFORE the first manifest read.
missing=0
for dep in git age jq; do
if command -v "$dep" >/dev/null 2>&1; then
echo " ok $dep"
else
echo " MISSING $dep — install it with:"
echo " $(install_hint "$dep")"
missing=1
fi
done
echo ""
if [ "$missing" -ne 0 ]; then
echo "Install the missing dependencies above, then re-run ./install.sh." >&2
exit 1
fi
cat <<EOF
All dependencies present. Two steps to finish:
1) Put 'secrets' on your PATH. Add this line to your shell config
(~/.zshrc or ~/.bashrc), then restart your terminal:
export PATH="$TOOL_DIR:\$PATH"
2) Onboard this machine:
First machine (new vault):
secrets init --remote <your-private-repo-url>
# then transfer key.txt to your other machines (AirDrop / scp / USB):
# scp <this-host>:$HOME/.secrets/key.txt ~/.secrets/key.txt
Other machine (join an existing vault):
secrets join --remote <your-private-repo-url> --key <path-to-key.txt>
# 'join' clones the vault, installs the key, and VERIFIES it decrypts
# before declaring success — a mis-copied key fails loudly, not silently.
To update the tool later:
git -C "$TOOL_DIR" pull
EOF

235
secrets
View file

@ -1235,6 +1235,25 @@ ensure_store_protections() {
cmd_init() { cmd_init() {
check_cmd age check_cmd age
check_cmd git 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 <url>] [--yes]" ;;
*) die "Unexpected argument to init: $1" ;;
esac
done
resolve_store resolve_store
if [ -d "$SECRETS_DIR/.git" ]; then if [ -d "$SECRETS_DIR/.git" ]; then
@ -1242,17 +1261,15 @@ cmd_init() {
fi fi
# Second-machine trap: a copied key.txt without a repo means the user # Second-machine trap: a copied key.txt without a repo means the user
# should clone their existing secrets repo, not init a fresh one. # should join their existing vault, not init a fresh one. Catch it BEFORE
# Catch it BEFORE git init so we don't leave a half-initialized store. # git init so we don't leave a half-initialized store.
if [ -f "$KEY_FILE" ]; then 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>" local clone_src="<your-secrets-remote>"
[ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL" [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL"
die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR. 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: If this is a second machine, don't run 'secrets init' — join your existing vault:
git clone $clone_src $SECRETS_DIR secrets join --remote $clone_src --key $KEY_FILE
Your key file has been left untouched." Your key file has been left untouched."
fi fi
@ -1268,9 +1285,7 @@ Your key file has been left untouched."
# Write .gitignore # Write .gitignore
write_store_gitignore write_store_gitignore
# Stamp the store format (EGB-703): a fresh store is born v2 — it has no # Stamp the store format (EGB-703): a fresh store is born v2.
# 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" printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
# Install pre-commit hook # Install pre-commit hook
@ -1282,11 +1297,159 @@ Your key file has been left untouched."
info "Done! Your public key is:" info "Done! Your public key is:"
echo " $pubkey" echo " $pubkey"
if [ -n "$remote" ]; then
_init_wire_remote "$remote"
else
echo "" echo ""
echo "Next steps:" echo "Next steps:"
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>" echo " 1. Add a remote: secrets init --remote <url> (or: git -C $SECRETS_DIR remote add origin <url>)"
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)" echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
echo " 3. Run 'secrets push <project>' from a project directory" echo " 3. On the other machine: secrets join --remote <url> --key <path-to-key.txt>"
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 <path-to-key.txt>"
}
# 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 <url> --key <path>" ;;
*) die "Unexpected argument to join: $1" ;;
esac
done
resolve_store
[ -n "$remote" ] || die "secrets join requires --remote <url>
Usage: secrets join --remote <url> --key <path-to-key.txt>"
[ -n "$keyfile" ] || die "secrets join requires --key <path>
This is the age key (key.txt) from your first machine.
Usage: secrets join --remote <url> --key <path-to-key.txt>"
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. # Encrypt env files from a source dir into a project path in the secrets repo.
@ -1498,8 +1661,22 @@ cmd_push() {
'.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \ '.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \
| _write_manifest_canonical "$manifest" || die "Failed to update $manifest" | _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
else else
jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \ # EGB-671 / EGB-677 contract #2: scaffolding the project's FIRST manifest.
'{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \ # 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" | _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
fi fi
if [ "$write_adds" = true ]; then if [ "$write_adds" = true ]; then
@ -1616,9 +1793,14 @@ cmd_pull() {
continue continue
fi fi
case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob" # 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 if [ ! -s "$target_dir/$rel" ]; then
echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$rel' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done <<< "$declared" done <<< "$declared"
@ -1642,10 +1824,14 @@ cmd_pull() {
local name local name
name=$(basename "$f" .age) name=$(basename "$f" .age)
local outfile="$target_dir/$name" local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f" # 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 # Integrity check: verify non-empty
if [ ! -s "$outfile" ]; then if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done done
@ -1674,9 +1860,13 @@ pull_project_to_dir() {
local name local name
name=$(basename "$f" .age) name=$(basename "$f" .age)
local outfile="$target_dir/$name" local outfile="$target_dir/$name"
age -d -i "$KEY_FILE" -o "$outfile" "$f" # 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 if [ ! -s "$outfile" ]; then
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)" echo "WARNING: Decrypted file '$name' is empty (an empty source file, or a truncated .age blob)"
fi fi
count=$((count + 1)) count=$((count + 1))
done done
@ -2523,6 +2713,10 @@ secrets — encrypted secret file sync between machines
Usage: Usage:
secrets init Initialize the secrets repo and generate an age key secrets init Initialize the secrets repo and generate an age key
secrets init --remote <url> Init, wire the remote, and push the initial store
secrets join --remote <url> --key <path>
Join an existing vault on a new machine: clone,
install the key, and verify it decrypts the store
secrets push [project] Encrypt secret files and push to the secrets repo 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 --frozen Sync only manifest-declared files (skip auto-add)
secrets push --dry-run Show what would be added/synced; change nothing secrets push --dry-run Show what would be added/synced; change nothing
@ -2679,7 +2873,8 @@ else
fi fi
case "${1:-help}" in case "${1:-help}" in
init) cmd_init ;; init) shift; cmd_init "$@" ;;
join) shift; cmd_join "$@" ;;
push) push)
if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
cmd_push_workspaces cmd_push_workspaces

72
test/install.bats Normal file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bats
# EGB-671: install.sh thin bootstrap. It ships IN the repo (you clone the repo
# to get it), so its job is: verify deps (age + jq + git), PRINT the PATH line
# and next-step commands — never edit dotfiles, never invoke sudo. Security-rail
# concerns are operator-local (.ship-policy.json); these are functional checks.
load test_helper
INSTALL_SH="$(cd "$(dirname "${BATS_TEST_FILENAME}")/.." && pwd)/install.sh"
@test "install.sh exists and is executable" {
[ -f "$INSTALL_SH" ]
[ -x "$INSTALL_SH" ]
}
@test "install.sh --help prints usage and exits 0" {
run "$INSTALL_SH" --help
[ "$status" -eq 0 ]
[[ "$output" == *"install.sh"* ]] || false
[[ "$output" == *"join"* ]] || false
}
@test "install.sh prints the PATH export line for the tool dir (does not edit rc)" {
local tool_dir
tool_dir="$(cd "$(dirname "$INSTALL_SH")" && pwd)"
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"export PATH="* ]] || false
[[ "$output" == *"$tool_dir"* ]] || false
# It must NOT have written to any shell rc in the isolated HOME.
[ ! -f "$HOME/.zshrc" ]
[ ! -f "$HOME/.bashrc" ]
}
@test "install.sh prints both onboarding next-steps (init --remote and join)" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"secrets init --remote"* ]] || false
[[ "$output" == *"secrets join --remote"* ]] || false
}
@test "install.sh prints the upgrade one-liner" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"git -C"* ]] || false
[[ "$output" == *"pull"* ]] || false
}
@test "install.sh prints a key-transfer hint" {
run "$INSTALL_SH"
[ "$status" -eq 0 ]
[[ "$output" == *"key.txt"* ]] || false
}
@test "install.sh never invokes sudo (prints it for the user instead)" {
# No executed 'sudo' — any sudo reference must be quoted guidance text.
run grep -nE '^[[:space:]]*sudo ' "$INSTALL_SH"
[ "$status" -ne 0 ]
}
@test "install.sh reports a missing dependency with an install hint and non-zero exit" {
# Build a minimal PATH that has the tools install.sh needs but NOT jq.
local fake="$TEST_TMPDIR/fakebin"
mkdir -p "$fake"
for t in bash uname env cat grep sed tr dirname command age git printf; do
src="$(command -v "$t" 2>/dev/null || true)"
[ -n "$src" ] && ln -sf "$src" "$fake/$t" 2>/dev/null || true
done
run env PATH="$fake" "$INSTALL_SH"
[ "$status" -ne 0 ]
[[ "$output" == *"jq"* ]] || false
}

145
test/join.bats Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env bats
# EGB-671: `secrets join` (second-machine onboarding) + `secrets init --remote`
# + day-2 silent-decrypt fix. Functional paths only — security-rail tests
# (path traversal on --key/--store, URL injection) are operator-local per
# .ship-policy.json and live in test/run-security.sh.
load test_helper
# Push a project to REMOTE_DIR and save the key, then remove the local store
# to simulate a fresh second machine. Leaves: REMOTE_DIR has blobs,
# $TEST_TMPDIR/saved-key.txt is the decrypting key, $SECRETS_DIR is gone.
_machine1_push_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
create_project_dir "joinproj"
"$SECRETS_BIN" push >/dev/null 2>&1
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# Like above but never pushes a project — remote has a store with zero blobs.
_machine1_empty_then_wipe() {
init_with_remote
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
cd "$HOME"
rm -rf "$SECRETS_DIR"
}
# ─── secrets join ────────────────────────────────────────────────────────
@test "join without --remote fails with usage" {
run "$SECRETS_BIN" join
[ "$status" -ne 0 ]
[[ "$output" == *"--remote"* ]] || false
}
@test "join clones the store, installs the key at 600, verifies, and succeeds" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"VERIFIED"* ]] || false
[ -d "$SECRETS_DIR/.git" ]
[ -f "$SECRETS_DIR/key.txt" ]
# key installed at mode 600
local perms
perms=$(stat -f '%Lp' "$SECRETS_DIR/key.txt" 2>/dev/null || stat -c '%a' "$SECRETS_DIR/key.txt")
[ "$perms" = "600" ]
}
@test "join with the wrong key fails loudly and does not report VERIFIED" {
_machine1_push_then_wipe
age-keygen -o "$TEST_TMPDIR/wrong-key.txt" 2>/dev/null
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/wrong-key.txt"
[ "$status" -ne 0 ]
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join against an empty store reports nothing-to-verify, NOT VERIFIED" {
_machine1_empty_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -eq 0 ]
[[ "$output" == *"nothing to verify"* ]] || false
[[ "$output" != *"VERIFIED"* ]] || false
}
@test "join refuses when a store already exists at the target" {
"$SECRETS_BIN" init >/dev/null 2>&1
cp "$SECRETS_DIR/key.txt" "$TEST_TMPDIR/saved-key.txt"
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/saved-key.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"already"* ]] || false
}
@test "join fails clearly when the key file is missing" {
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR/nope.txt"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
@test "join detects a directory passed as --key" {
_machine1_push_then_wipe
run "$SECRETS_BIN" join --remote "$REMOTE_DIR" --key "$TEST_TMPDIR"
[ "$status" -ne 0 ]
[[ "$output" == *"key"* ]] || false
}
# ─── secrets init --remote ────────────────────────────────────────────────
@test "init --remote sets origin and establishes an upstream branch" {
run "$SECRETS_BIN" init --remote "$REMOTE_DIR"
[ "$status" -eq 0 ]
run git -C "$SECRETS_DIR" remote get-url origin
[ "$status" -eq 0 ]
[ "$output" = "$REMOTE_DIR" ]
# upstream branch exists on the remote (so a later push won't ff-only die)
run git -C "$SECRETS_DIR" rev-parse --abbrev-ref '@{u}'
[ "$status" -eq 0 ]
}
@test "init --remote then push does not die on the brand-new remote" {
"$SECRETS_BIN" init --remote "$REMOTE_DIR" >/dev/null 2>&1
create_project_dir "freshproj"
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
[[ "$output" != *"Fast-forward pull failed"* ]] || false
}
@test "init with no flags still works (clean primitive)" {
run "$SECRETS_BIN" init
[ "$status" -eq 0 ]
[ -f "$SECRETS_DIR/key.txt" ]
}
@test "init does not hang on the first-add prompt when stdin is a tty but stdout is captured" {
# Regression: run-security.sh runs bats in a real terminal, so the command's
# stdin stays a tty while bats captures its stdout. The interactive first-add
# prompt must NOT fire in that shape (it gates on stdout being a tty too),
# or the whole suite hangs. Reproduce with a pty via `script`.
command -v script >/dev/null 2>&1 || skip "script (pty) not available"
# macOS/BSD syntax: `script -q <file> <cmd...>`. Skip on other syntaxes.
script -q /dev/null true >/dev/null 2>&1 || skip "unsupported script syntax"
local out="$TEST_TMPDIR/pty-initout"
run timeout 10 script -q /dev/null bash -c "'$SECRETS_BIN' init > '$out' 2>&1"
[ "$status" -ne 124 ] # 124 == timeout == it hung on a prompt
run grep -c "Add a project's secrets" "$out"
[ "$output" = "0" ]
}
# ─── day-2 silent-decrypt fix ─────────────────────────────────────────────
@test "pull dies loudly when a blob cannot be decrypted with the current key" {
init_with_remote
create_project_dir "decryptproj"
"$SECRETS_BIN" push >/dev/null 2>&1
# Swap in a different key so the stored blob no longer decrypts.
# (age-keygen refuses to overwrite, so generate elsewhere then copy.)
age-keygen -o "$TEST_TMPDIR/other-key.txt" 2>/dev/null
cp "$TEST_TMPDIR/other-key.txt" "$SECRETS_DIR/key.txt"
chmod 600 "$SECRETS_DIR/key.txt"
cd "$WORK_DIR/decryptproj"
rm -f .env .env.staging
run "$SECRETS_BIN" pull
[ "$status" -ne 0 ]
[[ "$output" == *"decrypt"* ]] || false
}

View file

@ -167,6 +167,17 @@ load test_helper
[ "$output" = "2" ] [ "$output" = "2" ]
} }
@test "bootstrap: first push writes an explicit options.autoAdd value (EGB-677 contract #2)" {
init_with_remote
create_project_dir autoaddproj
# Non-interactive (bats has no tty): the prompt is skipped and the tool
# default (ON) is written explicitly so the value is committed + team-shared.
run "$SECRETS_BIN" push
[ "$status" -eq 0 ]
run jq -r '.options.autoAdd' .secrets.json
[ "$output" = "true" ]
}
@test "failed push leaves no bootstrap manifest behind" { @test "failed push leaves no bootstrap manifest behind" {
init_with_remote init_with_remote
mkdir -p "$WORK_DIR/emptyproj" mkdir -p "$WORK_DIR/emptyproj"

View file

@ -1484,9 +1484,10 @@ gradle_project() {
# ─── init second-machine guard + store .gitignore self-heal ──────────── # ─── init second-machine guard + store .gitignore self-heal ────────────
@test "init with existing key but no repo dies with clone guidance" { @test "init with existing key but no repo dies with join guidance" {
# Second-machine trap: user copies key.txt into ~/.secrets, then runs # Second-machine trap (EGB-671): user copies key.txt into ~/.secrets, then
# `secrets init` instead of cloning their secrets repo. # runs `secrets init` instead of joining their existing vault. The trap now
# points at `secrets join` (the real one-command path), not a manual clone.
mkdir -p "$SECRETS_DIR" mkdir -p "$SECRETS_DIR"
age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null age-keygen -o "$SECRETS_DIR/key.txt" 2>/dev/null
# Guard against a vacuous '' = '' comparison if age-keygen failed # Guard against a vacuous '' = '' comparison if age-keygen failed
@ -1496,7 +1497,7 @@ gradle_project() {
run "$SECRETS_BIN" init run "$SECRETS_BIN" init
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
[[ "$output" == *"git clone"* ]] || false [[ "$output" == *"secrets join"* ]] || false
# Must not leave a half-initialized store behind # Must not leave a half-initialized store behind
[ ! -d "$SECRETS_DIR/.git" ] [ ! -d "$SECRETS_DIR/.git" ]
# Key untouched # Key untouched
@ -1651,7 +1652,7 @@ gradle_project() {
[[ "$output" != *"key.txt"* ]] || false [[ "$output" != *"key.txt"* ]] || false
} }
@test "init guard renders the real clone URL when .secrets-store carries a remote" { @test "init guard renders the real remote URL in join guidance when .secrets-store carries a remote" {
mkdir -p "$HOME/.secrets-work" mkdir -p "$HOME/.secrets-work"
age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null age-keygen -o "$HOME/.secrets-work/key.txt" 2>/dev/null
[ -s "$HOME/.secrets-work/key.txt" ] [ -s "$HOME/.secrets-work/key.txt" ]
@ -1660,7 +1661,7 @@ gradle_project() {
run "$SECRETS_BIN" init run "$SECRETS_BIN" init
[ "$status" -eq 1 ] [ "$status" -eq 1 ]
[[ "$output" == *"git clone git@example.com:me/secrets-work.git"* ]] || false [[ "$output" == *"secrets join --remote git@example.com:me/secrets-work.git"* ]] || false
} }
# ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ── # ─── EGB-652: `file` external type (whole-file sync, e.g. Android keystore) ──