initial commit
This commit is contained in:
commit
7eae4ea9a1
7 changed files with 808 additions and 0 deletions
383
secrets
Executable file
383
secrets
Executable file
|
|
@ -0,0 +1,383 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# secrets — encrypted env file sync between machines
|
||||
# Uses age key-file encryption + a private git repo.
|
||||
|
||||
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
|
||||
KEY_FILE="$SECRETS_DIR/key.txt"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||
info() { echo "==> $*"; }
|
||||
|
||||
check_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "'$1' is not installed. Run: brew install $1"
|
||||
}
|
||||
|
||||
check_initialized() {
|
||||
[ -d "$SECRETS_DIR/.git" ] || die "Not initialized. Run: secrets init"
|
||||
}
|
||||
|
||||
check_key() {
|
||||
[ -f "$KEY_FILE" ] || die "Key file not found at $KEY_FILE. Run: secrets init"
|
||||
}
|
||||
|
||||
get_pubkey() {
|
||||
age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
|
||||
}
|
||||
|
||||
derive_project_name() {
|
||||
local explicit="${1:-}"
|
||||
if [ -n "$explicit" ]; then
|
||||
echo "$explicit"
|
||||
return
|
||||
fi
|
||||
# Try git remote basename
|
||||
local remote
|
||||
remote=$(git config --get remote.origin.url 2>/dev/null || true)
|
||||
if [ -n "$remote" ]; then
|
||||
basename "$remote" .git
|
||||
return
|
||||
fi
|
||||
# Fall back to current directory name
|
||||
basename "$PWD"
|
||||
}
|
||||
|
||||
install_hook() {
|
||||
local hook_src="$SCRIPT_DIR/hooks/pre-commit"
|
||||
local hook_dst="$SECRETS_DIR/.git/hooks/pre-commit"
|
||||
if [ -f "$hook_src" ]; then
|
||||
cp "$hook_src" "$hook_dst"
|
||||
chmod +x "$hook_dst"
|
||||
else
|
||||
# Inline hook if template not found (e.g. secrets installed standalone)
|
||||
cat > "$hook_dst" << 'HOOKEOF'
|
||||
#!/usr/bin/env bash
|
||||
BLOCKED=$(git diff --cached --name-only | grep -E '\.env' | grep -v '\.age$' || true)
|
||||
if [ -n "$BLOCKED" ]; then
|
||||
echo "ERROR: Plaintext env files staged for commit:"
|
||||
echo "$BLOCKED"
|
||||
echo "Only .age (encrypted) files should be committed."
|
||||
exit 1
|
||||
fi
|
||||
HOOKEOF
|
||||
chmod +x "$hook_dst"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Subcommands ───────────────────────────────────────────────────────
|
||||
|
||||
cmd_init() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
|
||||
if [ -d "$SECRETS_DIR/.git" ]; then
|
||||
die "Already initialized at $SECRETS_DIR. Key file preserved."
|
||||
fi
|
||||
|
||||
info "Initializing secrets repo at $SECRETS_DIR"
|
||||
mkdir -p "$SECRETS_DIR"
|
||||
git init "$SECRETS_DIR" >/dev/null
|
||||
|
||||
# Generate age key pair
|
||||
info "Generating age key pair"
|
||||
age-keygen -o "$KEY_FILE" 2>&1
|
||||
|
||||
# Write .gitignore
|
||||
cat > "$SECRETS_DIR/.gitignore" << 'EOF'
|
||||
# Never commit the private key
|
||||
key.txt
|
||||
|
||||
# Block plaintext env files
|
||||
**/.env
|
||||
**/.env.*
|
||||
|
||||
# Allow encrypted env files
|
||||
!**/.env.age
|
||||
!**/.env.*.age
|
||||
EOF
|
||||
|
||||
# Install pre-commit hook
|
||||
mkdir -p "$SECRETS_DIR/.git/hooks"
|
||||
install_hook
|
||||
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
||||
info "Done! Your public key is:"
|
||||
echo " $pubkey"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Add a remote: cd $SECRETS_DIR && git remote add origin <url>"
|
||||
echo " 2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
|
||||
echo " 3. Run 'secrets push <project>' from a project directory"
|
||||
}
|
||||
|
||||
cmd_push() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
local project
|
||||
project=$(derive_project_name "${1:-}")
|
||||
info "Pushing secrets for project: $project"
|
||||
|
||||
# Glob .env and .env.* (not .envrc, .environment-*, etc.)
|
||||
local files=()
|
||||
for f in "$PWD"/.env "$PWD"/.env.*; do
|
||||
[ -f "$f" ] || continue
|
||||
local basename_f
|
||||
basename_f=$(basename "$f")
|
||||
# Skip patterns that aren't actual .env files
|
||||
case "$basename_f" in
|
||||
.envrc|.environment*) continue ;;
|
||||
esac
|
||||
files+=("$f")
|
||||
done
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
die "No .env or .env.* files found in $PWD"
|
||||
fi
|
||||
|
||||
info "Files to encrypt:"
|
||||
for f in "${files[@]}"; do
|
||||
echo " $(basename "$f")"
|
||||
done
|
||||
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
||||
# Encrypt each file
|
||||
mkdir -p "$SECRETS_DIR/$project"
|
||||
for f in "${files[@]}"; do
|
||||
local name
|
||||
name=$(basename "$f")
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
||||
done
|
||||
|
||||
# Pull before push (ff-only)
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then
|
||||
die "Fast-forward pull failed. Run 'secrets pull $project' first, then retry push."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Commit and push
|
||||
git -C "$SECRETS_DIR" add "$project/"
|
||||
if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
|
||||
info "No changes to push (secrets unchanged)"
|
||||
return
|
||||
fi
|
||||
git -C "$SECRETS_DIR" commit -m "update $project" >/dev/null
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
||||
info "Pushed $project to remote"
|
||||
else
|
||||
info "Committed $project locally (no remote configured)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_pull() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
local project
|
||||
project=$(derive_project_name "${1:-}")
|
||||
local target_dir="$PWD"
|
||||
info "Pulling secrets for project: $project"
|
||||
|
||||
# Pull latest
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "$SECRETS_DIR" pull >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Check project exists
|
||||
if [ ! -d "$SECRETS_DIR/$project" ]; then
|
||||
die "Project '$project' not found. Run: secrets list"
|
||||
fi
|
||||
|
||||
# Decrypt each .age file into target dir (including dotfiles)
|
||||
local count=0
|
||||
for f in "$SECRETS_DIR/$project"/*.age "$SECRETS_DIR/$project"/.*.age; do
|
||||
[ -f "$f" ] || continue
|
||||
local name
|
||||
name=$(basename "$f" .age)
|
||||
local outfile="$target_dir/$name"
|
||||
age -d -i "$KEY_FILE" -o "$outfile" "$f"
|
||||
# Integrity check: verify non-empty
|
||||
if [ ! -s "$outfile" ]; then
|
||||
echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)"
|
||||
fi
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
info "Decrypted $count file(s) into $target_dir"
|
||||
|
||||
# Reinstall hook if missing
|
||||
if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
|
||||
install_hook
|
||||
info "Reinstalled pre-commit hook"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
check_initialized
|
||||
|
||||
local found=0
|
||||
for dir in "$SECRETS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local project
|
||||
project=$(basename "$dir")
|
||||
# Skip hidden dirs
|
||||
[[ "$project" == .* ]] && continue
|
||||
echo "$project:"
|
||||
for f in "$dir"*.age "$dir".*.age; do
|
||||
[ -f "$f" ] || continue
|
||||
echo " $(basename "$f" .age)"
|
||||
found=1
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "No projects found. Run 'secrets push <project>' to add one."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
check_cmd git
|
||||
check_initialized
|
||||
|
||||
local project="${1:-}"
|
||||
[ -n "$project" ] || die "Usage: secrets rm <project>"
|
||||
|
||||
if [ ! -d "$SECRETS_DIR/$project" ]; then
|
||||
die "Project '$project' not found. Run: secrets list"
|
||||
fi
|
||||
|
||||
info "Removing project: $project"
|
||||
git -C "$SECRETS_DIR" rm -r "$project/" >/dev/null
|
||||
git -C "$SECRETS_DIR" commit -m "remove $project" >/dev/null
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
||||
info "Removed $project from remote"
|
||||
else
|
||||
info "Removed $project locally (no remote configured)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_rekey() {
|
||||
check_cmd age
|
||||
check_cmd git
|
||||
check_initialized
|
||||
check_key
|
||||
|
||||
# Create temp dir with cleanup trap
|
||||
local tmpdir
|
||||
tmpdir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmpdir"' EXIT INT TERM
|
||||
|
||||
info "Decrypting all files with current key..."
|
||||
|
||||
# Decrypt all .age files into temp dir
|
||||
local file_count=0
|
||||
for dir in "$SECRETS_DIR"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local project
|
||||
project=$(basename "$dir")
|
||||
[[ "$project" == .* ]] && continue
|
||||
mkdir -p "$tmpdir/$project"
|
||||
for f in "$dir"*.age "$dir".*.age; do
|
||||
[ -f "$f" ] || continue
|
||||
local name
|
||||
name=$(basename "$f" .age)
|
||||
if ! age -d -i "$KEY_FILE" -o "$tmpdir/$project/$name" "$f"; then
|
||||
die "Decryption failed for $project/$name. Rekey aborted. Old key preserved."
|
||||
fi
|
||||
file_count=$((file_count + 1))
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
die "No encrypted files found. Nothing to rekey."
|
||||
fi
|
||||
|
||||
info "Decrypted $file_count file(s). Generating new key pair..."
|
||||
|
||||
# Generate new key (overwrites old)
|
||||
age-keygen -o "$KEY_FILE" 2>&1
|
||||
local pubkey
|
||||
pubkey=$(get_pubkey)
|
||||
|
||||
info "Re-encrypting all files with new key..."
|
||||
|
||||
# Re-encrypt all files
|
||||
for dir in "$tmpdir"/*/; do
|
||||
[ -d "$dir" ] || continue
|
||||
local project
|
||||
project=$(basename "$dir")
|
||||
mkdir -p "$SECRETS_DIR/$project"
|
||||
for f in "$dir"*; do
|
||||
[ -f "$f" ] || continue
|
||||
local name
|
||||
name=$(basename "$f")
|
||||
age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
|
||||
done
|
||||
done
|
||||
|
||||
# Commit and push
|
||||
git -C "$SECRETS_DIR" add -A
|
||||
git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null
|
||||
if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "$SECRETS_DIR" push >/dev/null 2>&1
|
||||
info "Pushed rekeyed secrets to remote"
|
||||
else
|
||||
info "Committed rekeyed secrets locally (no remote configured)"
|
||||
fi
|
||||
|
||||
info "Rekey complete!"
|
||||
echo ""
|
||||
echo "IMPORTANT: Copy new key to your other machine:"
|
||||
echo " scp $KEY_FILE <other-machine>:$KEY_FILE"
|
||||
echo ""
|
||||
echo "WARNING: Old ciphertext remains in git history."
|
||||
echo "For full rotation, create a fresh repo."
|
||||
}
|
||||
|
||||
cmd_help() {
|
||||
cat << 'EOF'
|
||||
secrets — encrypted env file sync between machines
|
||||
|
||||
Usage:
|
||||
secrets init Initialize the secrets repo and generate an age key
|
||||
secrets push [project] Encrypt .env* files and push to the secrets repo
|
||||
secrets pull [project] Pull and decrypt .env* files into current directory
|
||||
secrets list List all projects and their secret files
|
||||
secrets rm <project> Remove a project's secrets from the repo
|
||||
secrets rekey Re-encrypt all secrets with a new key
|
||||
|
||||
If [project] is omitted, it is derived from the current directory's
|
||||
git remote (if available) or the directory name.
|
||||
|
||||
Environment:
|
||||
SECRETS_DIR Path to secrets repo (default: ~/.secrets)
|
||||
EOF
|
||||
}
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────
|
||||
|
||||
case "${1:-help}" in
|
||||
init) cmd_init ;;
|
||||
push) cmd_push "${2:-}" ;;
|
||||
pull) cmd_pull "${2:-}" ;;
|
||||
list) cmd_list ;;
|
||||
rm) cmd_rm "${2:-}" ;;
|
||||
rekey) cmd_rekey ;;
|
||||
help|--help|-h) cmd_help ;;
|
||||
*) die "Unknown command: $1. Run 'secrets help' for usage." ;;
|
||||
esac
|
||||
Loading…
Add table
Add a link
Reference in a new issue