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:
commit
57f1280262
9 changed files with 669 additions and 84 deletions
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -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
122
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.7.2.0
|
0.7.3.0
|
||||||
|
|
|
||||||
113
install.sh
Executable file
113
install.sh
Executable 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
|
||||||
239
secrets
239
secrets
|
|
@ -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 "Next steps:"
|
||||||
|
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 " 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 ""
|
||||||
echo "Next steps:"
|
echo "Next: copy $KEY_FILE to your other machine, then run there:"
|
||||||
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>"
|
echo " secrets join --remote $remote --key <path-to-key.txt>"
|
||||||
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
|
}
|
||||||
echo " 3. Run 'secrets push <project>' from a project directory"
|
|
||||||
|
# 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
72
test/install.bats
Normal 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
145
test/join.bats
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) ──
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue