Merge origin/main into EGB-283 (multi-recipient age encryption)

Reconcile the multi-recipient branch (cut from v0.6.1.0) with origin/main
at v0.7.4.0. The two feature lines are largely orthogonal; the one real
integration point is the external-blob encrypt path:

- EGB-712 added an additive-v2 dual-write loop (_external_blob_write_targets,
  writing v2 + any v1 twin). EGB-283 routes every encrypt site through
  RECIPIENT_ARGS for N-recipient encryption. Resolution keeps the dual-write
  loop but encrypts each target to the full recipient set
  (age "${RECIPIENT_ARGS[@]}" per write target), so dual-write and
  multi-recipient compose. cmd_push loads recipients before both external
  push sites; legacy single-key rekey keeps its fresh-keypair pubkey path.

Version: 0.6.2.0 + 0.7.4.0 -> 0.7.5.0. Docs (CLAUDE.md/README/CHANGELOG)
merged to carry both feature sets; subcommand list now includes
recipients/reencrypt and upgrade.

Tests: full `bats test/` green except 6 pre-existing host-environment
failures (4 chmod-600 restore assertions + 2 jq-PATH-shadow tests, all
macOS-authored), none touching merged code. recipients.bats 34/34 pass;
external/dual-write area passes except the same mode-600 host artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brian Majewski 2026-06-24 14:52:32 -07:00
commit e29024bd63
16 changed files with 2723 additions and 142 deletions

142
README.md
View file

@ -58,83 +58,95 @@ Beyond project files, `secrets` can also sync files that live *outside* the proj
## Prerequisites
- **macOS** (uses Homebrew for installation)
- **git** (already installed on most Macs — type `git --version` to check)
- **age** (the encryption tool — installed in step 1 below)
- **macOS or Linux**
- **git** (`git --version` to check)
- **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
### 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
# 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
# 3. Make the 'secrets' command available everywhere
# 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
cd ~/dev/secrets
./install.sh
```
> **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
On each new machine (your desktop, a teammate's laptop, etc.):
### First machine (new vault)
```bash
# 1. Install prerequisites and the tool (same as steps 1-3 above)
brew install age
git clone https://codeberg.org/egbt/secrets.git ~/dev/secrets
export PATH="$HOME/dev/secrets:$PATH" # add to ~/.zshrc
# 1. Create a PRIVATE repo for your encrypted secrets (github.com/new or a
# Codeberg/GitLab private repo). It holds only ciphertext — never your key.
# Then wire it up and push the store in one command:
secrets init --remote git@github.com:<you>/my-secrets.git
# 2. Clone the encrypted secrets repo
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
# 2. (optional) Add a project's secrets. From a project directory:
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.
### Sharing with teammates
**Simple approach (shared key):** To share secrets with a teammate, they need:
1. Access to your private `my-secrets` GitHub repo (add them as a collaborator)
2. A copy of `key.txt` (send it to them directly — AirDrop, USB, or in-person)
1. Access to your private secrets repo (add them as a collaborator)
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.
**Per-teammate keys (recommended for teams):** Use `secrets recipients add` so each person keeps their own private key — no key sharing needed. See [Onboarding and offboarding teammates](#onboarding-and-offboarding-teammates) below.
@ -169,17 +181,35 @@ secrets clear
| `secrets clear` | Delete plaintext secret files from the current directory |
| `secrets run <command>` | Pull secrets, run a command, then clear secrets when it exits |
| `secrets list` | Show all projects that have stored secrets |
| `secrets list --json` | Same listing as a machine-readable JSON object (`{store, projects[].entries[]}`, each entry `dotenv`/`external`) for tooling and CI. JSON goes to stdout; notices to stderr |
| `secrets rm <project>` | Delete a project's secrets from the store |
| `secrets rekey` | Generate a new encryption key and re-encrypt everything (single-key store) or re-encrypt to the current recipients without changing keys (multi-recipient store) |
| `secrets verify [project]` | Check the current project's `.secrets.json` against the store (missing/orphaned blobs) and decrypt every blob. `[project]` overrides the store directory name; the manifest is still read from the current directory |
| `secrets verify --all` | Decrypt-test every blob in every project — a store-wide integrity sweep |
| `secrets migrate [--dry-run]` | Copy-forward this project's encrypted blobs to store format v2 (non-destructive; manifest-free; `--dry-run` previews) |
| `secrets migrate --status` | Survey every project's v2 readiness; exits non-zero until the whole store is finalize-ready |
| `secrets migrate --finalize` | Drop the old v1 blobs and mark the store v2 — runs once, store-wide, after `verify` is green and every machine is upgraded |
| `secrets migrate --finalize` | **Optional GC** — drop the old v1 blobs and mark the store pure v2. Never required: upgraded clients dual-write and read-fall-back, so not finalizing never cuts anyone off |
| `secrets recipients list` | List the store's recipient public keys (and names if set) |
| `secrets recipients add <age1…> [--name N]` | Add a recipient key to the store and immediately re-encrypt every blob to the new set |
| `secrets recipients rm <key\|name> [--yes]` | Remove a recipient and re-encrypt the store; `--yes` required when removing your own key |
| `secrets reencrypt` | Re-encrypt every blob to the current recipients (idempotent — useful after a manual edit or partial failure) |
| `secrets upgrade` | Self-update the tool: `git pull --ff-only` on the `secrets` checkout, report old → new version, then re-check store version-skew. No auto-update, no background checks |
| `secrets upgrade --check` | Report whether an update is available (without pulling); changes nothing |
### Upgrading: do teammates on an older `secrets` get new secrets?
Store-format v2 is **additive** — an upgraded client reads either blob suffix and keeps the old (v1) suffix alive for externals that already existed, so you almost never have to coordinate an upgrade:
| Secret type | Old client gets it? |
|---|---|
| `.env` / `.env.*` / `.dev.vars` | **Yes, always** (blob name is identical across formats) |
| whole-file external (`file`) | **Yes, always** |
| `properties` external that already existed | **Yes** (dual-written so old clients stay fresh) |
| brand-new `properties` external | **No — must upgrade `secrets`** (the gentle forcing function) |
"Upgrade your secrets" = `git pull` the tool clone (binary ≥ 0.6.0.0) and/or `secrets migrate` the store. A read-only teammate only needs the tool `git pull`.
And you'll be told when you're behind: if a store was last written by a newer `secrets` than the one you're running, any command prints a one-line nudge to stderr (non-fatal) — and `secrets which` shows the store's `written-by:` version. Stores written by older builds (no version stamp) stay silent.
### Automatic project detection