knowledge-genome-orchestrator/lib/git-crypt.sh

213 lines
8.9 KiB
Bash

#!/usr/bin/env bash
# =============================================================================
# lib/git-crypt.sh
# git-crypt lifecycle management (init, export, verify, rotate).
# =============================================================================
gcrypt_init() {
git-crypt init
success "git-crypt initialized in $(pwd)"
}
gcrypt_export_key() {
local genome_name="$1"
local key_path="${KEYS_DIR}/${genome_name}.key"
mkdir -p "${KEYS_DIR}"
git-crypt export-key "$key_path"
success "Symmetric key exported to: $key_path"
warn "SECURITY ALERT: Move this key to Vaultwarden and delete it from disk immediately."
}
gcrypt_verify() {
local genome_name="$1"
info "Verifying git-crypt configuration for ${genome_name}..."
# `git-crypt status` reports the CONFIGURED status (from `.gitattributes`), not the
# lock/unlock status of the working tree. Encrypted lines have their labels right-aligned
# (with leading whitespace), so you CANNOT anchor on `^encrypted`.
# We filter by private/ and distinguish “encrypted” from “not encrypted” without
# relying on exact spacing.
local status_out encrypted_count not_encrypted_count
status_out=$(git-crypt status 2>/dev/null || true)
encrypted_count=$(printf '%s\n' "$status_out" | grep 'private/' | grep -cE '^[[:space:]]*encrypted:' || true)
not_encrypted_count=$(printf '%s\n' "$status_out" | grep 'private/' | grep -cE '^not encrypted:' || true)
if [[ "$encrypted_count" -gt 0 ]]; then
success "Encryption configured: ${encrypted_count} private file(s) under git-crypt."
if [[ "$not_encrypted_count" -gt 0 ]]; then
warn "${not_encrypted_count} file(s) under private/ are NOT covered by the git-crypt filter — check .gitattributes (leak risk)."
fi
elif [[ "$not_encrypted_count" -gt 0 ]]; then
warn "private/ files exist but none are covered by the git-crypt filter — check the .gitattributes filter (leak risk)."
else
info "No private/ files present yet — nothing to verify."
fi
}
# ---------------------------------------------------------------------------
# gcrypt_rotate_key <genome_name>
# Rotates the git-crypt symmetric key for the current genome directory.
#
# WHAT THIS DOES:
# 1. Unlocks the repo with the existing key (working tree is decrypted).
# 2. Removes the old key material from .git/git-crypt/keys/.
# 3. Runs git-crypt init to generate a new symmetric key.
# 4. Stages and commits private files — they are re-encrypted with the new key.
# 5. Exports the new key to KEYS_DIR for Vaultwarden upload.
#
# WHAT THIS DOES NOT DO (limitation):
# Git history still contains blobs encrypted with the OLD key. Anyone who
# has the old key and access to the git history can still decrypt those blobs.
# To purge old encrypted blobs from history entirely, run git-filter-repo
# separately after this function completes (manual step — not automated here
# because it rewrites all commit hashes and requires force-pushing).
#
# USAGE:
# source lib/git-crypt.sh
# cd ~/knowledge-genome-orchestrator/genome-dev
# gcrypt_rotate_key "genome-dev"
#
# REQUIRES:
# - The old key file at KEYS_DIR/<genome_name>.key OR the repo is already unlocked.
# - Clean working tree (no uncommitted changes outside private/).
# ---------------------------------------------------------------------------
gcrypt_rotate_key() {
local genome_name="$1"
local old_key_path="${KEYS_DIR}/${genome_name}.key"
local new_key_name="${genome_name}-rotated-$(date +%Y%m%d)"
step "Key rotation: ${genome_name}"
warn "SCOPE: this rotates the key for future commits only."
warn " Old git history retains blobs encrypted with the previous key."
warn " See function header in git-crypt.sh for full purge instructions."
echo ""
# 1. Unlock with old key (if not already unlocked)
if git-crypt status 2>/dev/null | grep -q "encrypted"; then
info "Repository appears to be locked. Attempting unlock..."
if [[ -f "$old_key_path" ]]; then
git-crypt unlock "$old_key_path"
success "Unlocked with existing key."
else
error "Old key not found at: ${old_key_path}"
error "Unlock manually before rotating: git-crypt unlock /path/to/${genome_name}.key"
return 1
fi
else
info "Repository is already unlocked — proceeding."
fi
# 2. Ensure working tree is clean (private files excluded — they will be re-staged)
if ! git diff --quiet -- ':!raw/private' ':!wiki/private' 2>/dev/null; then
error "Working tree has uncommitted changes outside private/. Commit or stash them first."
return 1
fi
# 3. Remove old key material only (preserves .git/git-crypt/ structure)
info "Removing old key material..."
rm -rf .git/git-crypt/keys
success "Old key material removed."
# 4. Re-initialize git-crypt (generates a new symmetric key)
info "Initializing new symmetric key..."
git-crypt init
success "New key generated."
# 5. Re-stage private files so they are committed encrypted with the new key
local staged=0
# compgen -G requires bash 4+ for reliable glob expansion. macOS stock
# bash is 3.2; use Homebrew bash (already recommended in README) for rotation.
if compgen -G "raw/private/*" > /dev/null 2>&1; then
git add raw/private/
staged=1
fi
if compgen -G "wiki/private/*" > /dev/null 2>&1; then
git add wiki/private/
staged=1
fi
if [[ $staged -eq 1 ]]; then
# Exclude .gitkeep-only commits — only commit if real content exists
if ! git diff --cached --quiet; then
git commit -m "security: rotate git-crypt key for ${genome_name}"
success "Private files re-committed with new key."
else
info "Only .gitkeep files in private/ — no content commit needed."
fi
else
info "No private files found to re-encrypt."
fi
# 6. Export new key
gcrypt_export_key "$new_key_name"
echo ""
success "Key rotation complete for: ${genome_name}"
echo ""
warn "NEXT STEPS:"
echo " 1. Push the new commit: git push origin main"
echo " 2. Upload the new key to Vaultwarden:"
echo " base64 < ${KEYS_DIR}/${new_key_name}.key"
echo " → Secure Note name: \"${genome_name} key\" (replace existing)"
echo " 3. Delete both key files from disk:"
echo " rm ${KEYS_DIR}/${genome_name}.key"
echo " rm ${KEYS_DIR}/${new_key_name}.key"
echo " 4. Revoke access from any previous key holders."
echo " 5. For full history purge (removes old encrypted blobs from git history):"
echo " git filter-repo --invert-paths --path raw/private --path wiki/private"
echo " git push --force origin main"
echo " (⚠ rewrites all commit hashes — coordinate with any collaborators)"
echo ""
}
gcrypt_print_key_instructions() {
local genome_name="$1"
local v_url="${VAULTWARDEN_URL:-https://your-vaultwarden.com}"
printf "\n ── %b ──\n\n" "${BOLD}Key Management: ${genome_name}${NC}"
echo " 1. Encode the key to base64:"
echo " base64 < ${KEYS_DIR}/${genome_name}.key"
echo ""
echo " 2. Save to Vaultwarden (${v_url}):"
echo " Name: \"${genome_name} key\""
echo " Note: <paste the base64 string>"
echo ""
echo " 3. Delete from disk:"
echo " rm ${KEYS_DIR}/${genome_name}.key"
echo ""
echo " 4. Runtime injection on AI server (no key on disk):"
echo " bw config server ${v_url}"
echo " export BW_SESSION=\$(bw unlock --passwordenv BW_MASTER_PASSWORD --raw)"
echo " git-crypt unlock <(bw get notes \"${genome_name} key\" --session \"\$BW_SESSION\" | base64 -d)"
echo ""
echo " NOTE: use 'bw' (standard Bitwarden CLI), NOT 'bws'."
echo " 'bws' is the Secrets Manager CLI and does not work with Vaultwarden."
}
gcrypt_print_runtime_model() {
echo ""
echo " RUNTIME SECURITY MODEL:"
echo " ─────────────────────────────────────────────────────────────"
echo " On Forgejo (remote):"
echo " raw/private/ and wiki/private/ are opaque AES-256-CTR blobs."
echo " Collaborators without the key see binary in private/,"
echo " plaintext everywhere else. Git handles this gracefully."
echo ""
echo " On your laptop:"
echo " Once unlocked, files are transparently decrypted by the git"
echo " smudge filter. Obsidian reads them as normal Markdown."
echo ""
echo " On the AI VM:"
echo " Same as laptop when unlocked. Use runtime injection so the"
echo " key is never written to disk."
echo ""
echo " Limitation:"
echo " Encryption does NOT protect against a full server compromise"
echo " where an attacker has root access to an already-unlocked repo."
echo " Runtime injection mitigates this risk."
echo " ─────────────────────────────────────────────────────────────"
echo ""
}