#!/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 # 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/.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: " 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 "" }