Building a Recoverable Encrypted Secrets Vault with git-crypt

tech-brief · 75% AI

Post 2026-A-0059

API keys, SSH private keys, OAuth tokens, and project .env files are strewn across a machine’s filesystem. Backing them up in plaintext is a security risk; not backing them up at all is a disaster when a hard drive dies or a laptop gets stolen.

This brief walks through a practical solution: a private, encrypted git repository that stores every secret in one place. The repo is encrypted with git-crypt, backed up to GitHub, and recoverable from a Bitwarden vault entry using an automated test script.

The setup uses three layers of secrets storage: the macOS Keychain (for credentials used by the automation scripts), Bitwarden (for the git-crypt decryption key), and git-crypt itself (for the repo contents). Each layer unlocks the next: the Keychain authenticates to Bitwarden, Bitwarden holds the git-crypt key, and git-crypt decrypts the repo.

Prerequisites

Requirement Purpose
macOS (Apple Silicon) Keychain access via security CLI; file paths assume macOS conventions
Git Version control
Homebrew Installing git-crypt and Bitwarden CLI
GitHub account Remote repository host
Bitwarden account Storing the git-crypt recovery key
jq Parsing Bitwarden CLI output (install via brew install jq)

The steps below use exampleuser as a placeholder for GitHub and Bitwarden usernames. Replace it with your own values.

Step 1: Create the Repository

cd ~/av/prj
mkdir exampleuser-vault
cd exampleuser-vault
git init

Create the directory structure for the secrets you want to track:

mkdir -p hermes/profiles/{glm52,dspro,kimi,sonnet} \
         ssh gpg/private-keys-v1.d claude \
         projects/{peertube,searxng,firecrawl/api} \
         personal
Directory Content Sync mode
hermes/.env Global Hermes Agent environment variables Source-of-truth (symlink back)
hermes/profiles/*/.env Per-profile .env files Source-of-truth (symlink back)
hermes/auth.json OAuth tokens Mirror (copied in)
hermes/gateway_state.json Platform bot tokens Mirror
hermes/channel_directory.json Webhook URLs and channel IDs Mirror
hermes/processes.json Managed process configurations Mirror
ssh/ SSH private keys and config Mirror
gpg/ GPG private keys Mirror
claude/.credentials.json Claude Code API tokens Mirror
projects/ Project .env files (PeerTube, SearXNG, etc.) Mirror
personal/ Personal recovery codes, PGP keys, etc. Add manually

Source-of-truth means the repo holds the canonical copy and the filesystem location is a symlink to the repo – edit in the repo and the original sees the change. Mirror means the original file stays in place and sync.sh copies it into the repo; never edit inside the repo directory for mirrors.

Step 2: Install and Initialize git-crypt

brew install git-crypt
cd ~/av/prj/exampleuser-vault
git-crypt init

This generates a 148-byte symmetric key stored in .git/git-crypt/keys/default. This key is the single point of failure for the entire vault: lose it and the encrypted files are permanently unreadable.

Step 3: Configure Encryption Rules

Create .gitattributes to control which files are encrypted:

# Hermes secrets
hermes/.env                filter=git-crypt diff=git-crypt
hermes/auth.json           filter=git-crypt diff=git-crypt
hermes/gateway_state.json  filter=git-crypt diff=git-crypt
hermes/channel_directory.json filter=git-crypt diff=git-crypt
hermes/processes.json      filter=git-crypt diff=git-crypt
hermes/profiles/*/.env     filter=git-crypt diff=git-crypt

# SSH
ssh/id_ed25519             filter=git-crypt diff=git-crypt
ssh/id_rsa                 filter=git-crypt diff=git-crypt
ssh/config                 filter=git-crypt diff=git-crypt
ssh/id_*.pub               filter=git-crypt diff=git-crypt

# GPG
gpg/**                     filter=git-crypt diff=git-crypt

# Claude
claude/.credentials.json   filter=git-crypt diff=git-crypt

# Projects
projects/**/.env           filter=git-crypt diff=git-crypt
projects/**/.env.local     filter=git-crypt diff=git-crypt

# Personal
personal/**                filter=git-crypt diff=git-crypt

# Plaintext (never encrypted)
.gitattributes             !filter !diff
.gitignore                 !filter !diff
sync.sh                    !filter !diff
README.md                  !filter !diff
test-recovery.sh           !filter !diff

The !filter !diff lines ensure those files are never picked up by git-crypt, even if a glob higher in the file matches them.

Step 4: Populate the Repository

Copy every secret file into its correct location:

# Hermes
cp ~/.hermes/.env                    hermes/
cp ~/.hermes/auth.json               hermes/
cp ~/.hermes/gateway_state.json      hermes/
cp ~/.hermes/channel_directory.json  hermes/
cp ~/.hermes/processes.json          hermes/
for p in glm52 dspro kimi sonnet; do
  cp ~/.hermes/profiles/$p/.env      hermes/profiles/$p/
done

# SSH
cp ~/.ssh/id_ed25519                 ssh/
cp ~/.ssh/id_ed25519.pub             ssh/
cp ~/.ssh/id_rsa                     ssh/
cp ~/.ssh/id_rsa.pub                 ssh/
cp ~/.ssh/config                     ssh/

# GPG
cp -a ~/.gnupg/private-keys-v1.d    gpg/

# Claude
cp ~/.claude/.credentials.json       claude/

# Projects
cp ~/av/prj/peertube/.env            projects/peertube/
cp ~/av/env/searxng/.env             projects/searxng/
cp ~/av/src/firecrawl/apps/api/.env  projects/firecrawl/api/

Files with strict permission requirements (SSH keys at chmod 600, .env files) are copied as mirror snapshots. The repository does not need to enforce permissions – only you will ever access it.

Step 5: Verify Encryption and Commit

git add -A
git-crypt status

Every sensitive file should show as encrypted. Plaintext files (.gitattributes, sync.sh, README.md) should show as not encrypted.

git commit -m "init: exampleuser-vault with git-crypt"

For files that change frequently (Hermes .env), edit them in the repo and symlink the original location back to the repo:

VAULT=~/av/prj/exampleuser-vault

# Backup originals
cp ~/.hermes/.env ~/.hermes/.env.backup-$(date +%F)
rm ~/.hermes/.env
ln -s "$VAULT/hermes/.env" ~/.hermes/.env

for p in glm52 dspro kimi sonnet; do
  cp ~/.hermes/profiles/$p/.env ~/.hermes/profiles/$p/.env.backup-$(date +%F)
  rm ~/.hermes/profiles/$p/.env
  ln -s "$VAULT/hermes/profiles/$p/.env" ~/.hermes/profiles/$p/.env
done

Now editing ~/av/prj/exampleuser-vault/hermes/.env immediately updates ~/.hermes/.env and vice versa. Hermes Agent sees the change without any additional steps.

Step 7: Create the Sync Script

The sync script copies mirror files into the repo, stages changes, commits, and pushes to GitHub. Source-of-truth files (the symlinked .env files) are already in the repo – the script just ensures they’re staged.

#!/bin/bash
set -euo pipefail

REPO="$HOME/av/prj/exampleuser-vault"
cd "$REPO"

echo "[sync] Mirroring secrets into exampleuser-vault..."

# ---- Hermes (mirror) ----
cp "$HOME/.hermes/auth.json"               hermes/auth.json
cp "$HOME/.hermes/gateway_state.json"       hermes/gateway_state.json
cp "$HOME/.hermes/channel_directory.json"   hermes/channel_directory.json
cp "$HOME/.hermes/processes.json"           hermes/processes.json

# ---- SSH (mirror) ----
cp "$HOME/.ssh/id_ed25519"                  ssh/id_ed25519
cp "$HOME/.ssh/id_ed25519.pub"              ssh/id_ed25519.pub
cp "$HOME/.ssh/id_rsa"                      ssh/id_rsa
cp "$HOME/.ssh/id_rsa.pub"                  ssh/id_rsa.pub
cp "$HOME/.ssh/config"                      ssh/config

# ---- GPG (mirror) ----
rm -rf gpg/private-keys-v1.d
cp -a "$HOME/.gnupg/private-keys-v1.d"     gpg/private-keys-v1.d

# ---- Claude (mirror) ----
cp "$HOME/.claude/.credentials.json"        claude/.credentials.json

# ---- Projects (mirror) ----
cp "$HOME/av/prj/peertube/.env"             projects/peertube/.env    2>/dev/null || true
cp "$HOME/av/env/searxng/.env"              projects/searxng/.env     2>/dev/null || true
cp "$HOME/av/src/firecrawl/apps/api/.env"   projects/firecrawl/api/.env 2>/dev/null || true

# ---- Stage, commit, push ----
git add -A
if git diff --cached --quiet; then
    echo "[sync] Nothing changed."
    exit 0
fi

git commit -m "sync $(date +%Y-%m-%d_%H%M)"
git push
echo "[sync] Done -- pushed to origin."

Make it executable: chmod +x sync.sh.

Step 8: Create the Private GitHub Repository

brew install gh          # if not already installed
gh auth login            # authenticate with your GitHub account
cd ~/av/prj/exampleuser-vault
gh repo create exampleuser-vault --private --source=. --push

This creates the private repository, sets the remote, and pushes the initial commit. At this point, the encrypted files are on GitHub and cannot be read by anyone without the git-crypt key.

Step 9: Store the git-crypt Key in Bitwarden

The git-crypt key must be recoverable without access to the repo itself. Storing it in Bitwarden creates a clean recovery path.

9.1 Install Bitwarden CLI

brew install bitwarden-cli
bw login                     # interactive login with email + master password

9.2 Create a Secure Note

export BW_SESSION="<from_bw_login>"
B64_KEY=$(base64 < ~/exampleuser-vault-key)

bw get template item | \
  jq --arg name "exampleuser-vault-gitcrypt" \
     --arg notes "Git-crypt key for https://github.com/exampleuser/exampleuser-vault.git

Recovery:
  echo \"<paste_key>\" | base64 -d > /tmp/git-crypt-key
  cd exampleuser-vault
  git-crypt unlock /tmp/git-crypt-key

Key (base64):
$B64_KEY" \
  '.type = 2 | .name = $name | .notes = $notes | .secureNote = {type: 0}' | \
  bw encode | bw create item --session "$BW_SESSION"

The note name (exampleuser-vault-gitcrypt) is what the recovery script searches for. Keep it consistent.

9.3 Export a Local Copy

git-crypt export-key ~/exampleuser-vault-key

This is a 148-byte binary file. It can also be stored on a USB drive or printed as hex paper backup.

Step 10: Store Bitwarden Credentials in macOS Keychain

The recovery script needs to authenticate to Bitwarden automatically. Store the master password in the macOS Keychain rather than in a file or environment variable.

Create a storage script:

cat > ~/av/bin/store-bw-master-pass.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail

echo "=== Bitwarden Master Password Storage ==="
read -s -p "Master password: " PASS
echo
read -s -p "Confirm master password: " CONFIRM
echo

if [ "$PASS" != "$CONFIRM" ]; then
  echo "FAIL: Passwords do not match."
  exit 1
fi

security add-generic-password -s "bitwarden" -a "BW_MASTER_PASS" -w "$PASS"
echo "Stored in macOS Keychain."
SCRIPT
chmod +x ~/av/bin/store-bw-master-pass.sh

The script prompts twice and verifies a match before storing, preventing a mistyped password from locking you out during recovery. Run it once:

~/av/bin/store-bw-master-pass.sh

The Bitwarden email can be stored similarly if bw status does not have it cached:

security add-generic-password -s "bitwarden" -a "BW_EMAIL" -w "[email protected]"

Step 11: Create the Recovery Test Script

The recovery test validates the entire chain end-to-end: authenticate to Bitwarden, fetch the git-crypt key, clone the repo, unlock it, and verify every file decrypts correctly. It runs in a temp directory and cleans up after itself.

#!/bin/bash
set -euo pipefail

VAULT_REPO="https://github.com/exampleuser/exampleuser-vault.git"
BW_ITEM_NAME="exampleuser-vault-gitcrypt"
TMPDIR=$(mktemp -d /tmp/vault-test-XXXXX)
trap 'rm -rf "$TMPDIR"' EXIT

FAIL=0

login_bw() {
  local STATUS
  STATUS=$(bw status 2>/dev/null | jq -r '.status // "unauthenticated"')
  if [ "$STATUS" = "unlocked" ]; then
    echo "[0/4] Vault already unlocked"
    return
  fi

  local PASS
  PASS=$(security find-generic-password -s "bitwarden" -a "BW_MASTER_PASS" -w 2>/dev/null) || true
  if [ -z "$PASS" ]; then
    echo "FAIL: Master password not in keychain."
    echo "       Run ~/av/bin/store-bw-master-pass.sh"
    exit 1
  fi

  local EMAIL
  EMAIL=$(bw status 2>/dev/null | jq -r '.userEmail // empty')
  if [ -z "$EMAIL" ]; then
    EMAIL=$(security find-generic-password -s "bitwarden" -a "BW_EMAIL" -w 2>/dev/null) || true
  fi
  if [ -z "$EMAIL" ]; then
    echo "FAIL: Could not determine Bitwarden email."
    exit 1
  fi

  if [ "$STATUS" != "unauthenticated" ]; then
    bw logout --quiet 2>/dev/null || true
  fi

  echo "[0/4] Logging in to Bitwarden..."
  export BW_MASTER_PASS="$PASS"
  BW_SESSION=$(bw login "$EMAIL" --passwordenv BW_MASTER_PASS --raw 2>/dev/null) || {
    echo "FAIL: Login failed."
    exit 1
  }
  unset BW_MASTER_PASS
  PASS=""
  export BW_SESSION
  echo "  OK -- logged in"
}

echo "=== exampleuser-vault recovery test ==="
echo
login_bw

echo "[1/4] Fetching key from Bitwarden..."
NOTE=$(bw list items --search "$BW_ITEM_NAME" --session "$BW_SESSION" 2>/dev/null)
ITEM_ID=$(echo "$NOTE" | jq -r '.[0].id // empty')
[ -z "$ITEM_ID" ] && { echo "FAIL: Item not found"; exit 1; }

NOTES=$(bw get item "$ITEM_ID" --session "$BW_SESSION" | jq -r '.notes // empty')
B64_KEY=$(echo "$NOTES" | sed -n '/^Key (base64):/,$ p' | tail -n +2 | tr -d '[:space:]')
[ -z "$B64_KEY" ] && { echo "FAIL: Key not found in note"; exit 1; }
echo "  OK -- found item $ITEM_ID"
echo

echo "[2/4] Cloning repo..."
git clone --quiet "$VAULT_REPO" "$TMPDIR/repo" 2>&1
echo "  OK -- cloned"
echo

echo "[3/4] Unlocking with git-crypt..."
echo "$B64_KEY" | base64 -d > "$TMPDIR/git-crypt-key"
cd "$TMPDIR/repo"
git-crypt unlock "$TMPDIR/git-crypt-key" 2>&1
echo "  OK -- unlocked"
echo

echo "[4/4] Verifying decrypted content..."
check_file() {
  local path="$1" label="$2"
  if [ ! -f "$TMPDIR/repo/$path" ]; then
    echo "  FAIL: $label -- missing"; return 1
  fi
  local ft; ft=$(file "$TMPDIR/repo/$path")
  if echo "$ft" | grep -qiE "text|ASCII|JSON|OpenSSH|PEM|RSA"; then
    echo "  OK: $label -- $(echo "$ft" | cut -d: -f2)"
  else
    echo "  FAIL: $label -- still encrypted"; return 1
  fi
}

check_file "hermes/.env"             "Hermes .env"
check_file "hermes/auth.json"        "Auth JSON"
check_file "ssh/id_ed25519"          "SSH private key"
check_file "ssh/id_rsa"              "SSH RSA key"
check_file "claude/.credentials.json" "Claude credentials"
check_file "ssh/config"              "SSH config"

if grep -q "export" "$TMPDIR/repo/hermes/.env" 2>/dev/null; then
  echo "  OK: hermes/.env has expected content"
else
  echo "  FAIL: hermes/.env wrong content"; FAIL=1
fi

echo
[ "$FAIL" -eq 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }

The file command is the key content check: git-crypt stores encrypted files as binary data. If file reports JSON, ASCII, or OpenSSH private key, the file decrypted correctly. If it reports “data” (binary), the file is still encrypted.

Step 12: Push the Infrastructure

Commit everything – the sync script, recovery test, and this README – to the vault repo:

cd ~/av/prj/exampleuser-vault
git add sync.sh test-recovery.sh README.md
git commit -m "feat: sync, recovery test, and documentation"
git push

Step 13: Schedule Daily Sync

Add a cron entry to push changes automatically:

(crontab -l 2>/dev/null; echo "# exampleuser-vault -- daily at 6:30am")
(crontab -l 2>/dev/null; \
  echo "30 6 * * * $HOME/av/prj/exampleuser-vault/sync.sh >> \
    $HOME/av/log/exampleuser-vault-sync.log 2>&1") | crontab -

This runs at 6:30 AM daily, after other early-morning maintenance jobs. The log file captures any errors.

Recovery Procedure

On a new machine, restore from scratch:

Step A: Install Prerequisites

brew install git git-crypt jq bitwarden-cli

Step B: Clone and Unlock

git clone https://github.com/exampleuser/exampleuser-vault.git
cd exampleuser-vault

# Retrieve the key from Bitwarden
bw login
# Open the "exampleuser-vault-gitcrypt" secure note
# Copy the base64 key from the notes field

echo "<pasted_base64>" | base64 -d > /tmp/git-crypt-key
git-crypt unlock /tmp/git-crypt-key
rm /tmp/git-crypt-key
VAULT="$PWD"

# Hermes .env (source-of-truth -- symlink)
ln -sf "$VAULT/hermes/.env" ~/.hermes/.env
for p in glm52 dspro kimi sonnet; do
  ln -sf "$VAULT/hermes/profiles/$p/.env" ~/.hermes/profiles/$p/.env
done

# SSH (mirror -- copy, set perms)
cp ssh/id_ed25519 ~/.ssh/ && chmod 600 ~/.ssh/id_ed25519
cp ssh/config ~/.ssh/

# Claude (mirror -- copy)
cp claude/.credentials.json ~/.claude/

Step D: Restore Automation

# Bitwarden master password in keychain
~/av/bin/store-bw-master-pass.sh

# Cron job
(crontab -l 2>/dev/null; echo "30 6 * * * $VAULT/sync.sh >> \
  ~/av/log/exampleuser-vault-sync.log 2>&1") | crontab -

Step E: Verify

cd ~/av/prj/exampleuser-vault && bash test-recovery.sh

Security Considerations

Threat Mitigation
GitHub breach exposes encrypted files git-crypt uses AES-256-GCM; without the key, files are unreadable
Bitwarden account compromised Master password + email + 2FA protect the vault
macOS Keychain unlocked on stolen machine Same threat model as Bitwarden being open on a stolen phone – both require the device to be unlocked
git-crypt key lost Two independent copies: Bitwarden secure note and macOS Keychain; optionally paper backup
Attacker gains physical access to unlocked Mac Both Keychain and Bitwarden are accessible – this is a physical security problem, not a cryptography one
cron job fails silently Logs to ~/av/log/exampleuser-vault-sync.log; recovery test catches failures proactively

The weakest link in any secrets management system is the human who types the password. The double-entry confirmation in store-bw-master-pass.sh prevents a mistyped master password from bricking recovery.

Key Architecture Decisions

Why git-crypt and not a password manager? Password managers are optimized for individual entries, not for version-controlled collections of files. git-crypt gives you git history (who changed what, when), diffs between versions (for JSON config files), and a single git pull to sync.

Why symlinks for source-of-truth files? Files that the system reads from a fixed path (like ~/.hermes/.env) cannot be moved without breaking the application. Symlinks allow the canonical copy to live in the repo while the application sees the expected path.

Why mirror (copy) for SSH keys? SSH keys require chmod 600 permissions. If the repo were the canonical copy, a careless git clone would leave them world-readable. Mirroring means the canonical copy stays in ~/.ssh/ with correct permissions; the repo gets a snapshot for backup.

Why Bitwarden and not just macOS Keychain? Keychain is tied to this Mac. If the machine is destroyed physically, the git-crypt key dies with it. Bitwarden is cloud-synced and recoverable from any device, making it the cross-machine recovery layer.

Why a full recovery test script? An encrypted repo is worthless if the key doesn’t work, if the Bitwarden note got corrupted, or if a file wasn’t actually encrypted. The test script validates the entire chain every time and would catch, for example, a .gitattributes change that accidentally left a file unencrypted.

Reference

Component Location
Vault repository ~/av/prj/exampleuser-vault/
GitHub remote https://github.com/exampleuser/exampleuser-vault.git
git-crypt key Bitwarden secure note “exampleuser-vault-gitcrypt”
git-crypt key (local) macOS Keychain “exampleuser-vault-gitcrypt”
BW master password macOS Keychain “bitwarden”/“BW_MASTER_PASS”
Sync script ~/av/prj/exampleuser-vault/sync.sh
Recovery test ~/av/prj/exampleuser-vault/test-recovery.sh
BW credential store ~/av/bin/store-bw-master-pass.sh
Sync log ~/av/log/exampleuser-vault-sync.log

Version: 1.0 (2026-07-28)


Want to stay in touch?

Support my work