diff --git a/Brewfile b/Brewfile index e5207a2..bf5c28c 100644 --- a/Brewfile +++ b/Brewfile @@ -1,5 +1,6 @@ # CLI tools for blumeops management brew "actionlint" # GitHub/Forgejo Actions workflow linter +brew "age" # File encryption for 1Password backup (op-backup) brew "argocd" # ArgoCD CLI for GitOps management brew "bat" # Syntax-highlighted file concatenation brew "mise" # Task runner and toolchain manager diff --git a/docs/changelog.d/feature-op-backup.feature.md b/docs/changelog.d/feature-op-backup.feature.md new file mode 100644 index 0000000..cc2606a --- /dev/null +++ b/docs/changelog.d/feature-op-backup.feature.md @@ -0,0 +1 @@ +Add `op-backup` mise task for encrypted 1Password disaster recovery backups via borgmatic diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup new file mode 100755 index 0000000..d9841b9 --- /dev/null +++ b/mise-tasks/op-backup @@ -0,0 +1,234 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = ["rich>=13.0.0"] +# /// +#MISE description="Encrypt a 1Password .1pux export and send to indri for borgmatic" +#USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)" +"""Encrypt a 1Password export and transfer to indri for borgmatic backup. + +Encrypts a .1pux file with age using your 1Password master password and secret +key as the passphrase, SCPs the result to indri for borgmatic pickup, then +deletes the plaintext .1pux file. + +Usage: + mise run op-backup [path/to/export.1pux] + +If no path is given, prompts you to export from the 1Password desktop app first. + +DISASTER RECOVERY: + 1. Restore borgmatic archive to get the .age file + 2. Retrieve Emergency Kit from safety deposit box + 3. age --decrypt .age > export.1pux + 4. Passphrase: {master_password}:{secret_key} + 5. Open export.1pux with 1Password or unzip to inspect +""" + +import os +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + +from rich.console import Console + +REMOTE_DIR = "/Users/erichblume/Documents/1password-backup" +DEFAULT_EXPORT_PATH = Path.home() / "Documents" / "1Password-export.1pux" +KEEP_RECENT = 3 + +# 1Password vault/item references for the encryption credentials +OP_VAULT = "wpwhqn557rkb4ybpyvdxi5wsmu" +OP_ITEM = "nev7fcgapzcjtdlxxlhg5kz7ca" + +console = Console() + + +def check_dependencies() -> bool: + """Verify required CLI tools are installed.""" + ok = True + for cmd in ("op", "age", "ssh", "scp"): + if not shutil.which(cmd): + console.print(f"[red]ERROR:[/red] {cmd} is required but not installed") + if cmd == "age": + console.print(" Install with: brew install age") + ok = False + return ok + + +def get_export_path(argv_path: str | None) -> Path | None: + """Resolve the .1pux file path from argument or interactive prompt.""" + if argv_path: + path = Path(argv_path).expanduser() + else: + console.print("[bold]=== 1Password Disaster Recovery Backup ===[/bold]") + console.print() + console.print("Export your vaults from the 1Password desktop app:") + console.print(" 1. Open 1Password") + console.print(" 2. File > Export > All Vaults (or select specific vaults)") + console.print(f" 3. Save as 1PUX format to: [cyan]{DEFAULT_EXPORT_PATH}[/cyan]") + console.print() + raw = console.input(f"Path to .1pux file [{DEFAULT_EXPORT_PATH}]: ").strip() + path = Path(raw).expanduser() if raw else DEFAULT_EXPORT_PATH + + if not path.is_file(): + console.print(f"[red]ERROR:[/red] File not found: {path}") + return None + + if path.suffix != ".1pux": + console.print(f"[yellow]WARNING:[/yellow] File does not have .1pux extension: {path}") + confirm = console.input("Continue anyway? [y/N]: ").strip().lower() + if confirm != "y": + return None + + return path + + +def fetch_credentials() -> str | None: + """Fetch master password and secret key from 1Password (triggers biometric).""" + console.print("Fetching encryption credentials from 1Password...") + + password = _op_field("password") + if not password: + console.print("[red]ERROR:[/red] Failed to fetch password from 1Password") + return None + + secret_key = _op_field("Secret Key") + if not secret_key: + console.print("[red]ERROR:[/red] Failed to fetch secret key from 1Password") + return None + + return f"{password}:{secret_key}" + + +def _op_field(field: str) -> str | None: + """Retrieve a single field from the 1Password credentials item.""" + result = subprocess.run( + [ + "op", "--vault", OP_VAULT, + "item", "get", OP_ITEM, + "--fields", field, "--reveal", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def encrypt(export_path: Path, passphrase: str) -> Path | None: + """Encrypt the .1pux file with age, returning the encrypted temp file path.""" + console.print("Encrypting...") + + fd, tmp_path = tempfile.mkstemp(suffix=".age") + os.close(fd) + tmp = Path(tmp_path) + + env = {**os.environ, "AGE_PASSPHRASE": passphrase} + with open(export_path, "rb") as src, open(tmp, "wb") as dst: + result = subprocess.run( + ["age", "--encrypt", "--passphrase", "--armor"], + stdin=src, + stdout=dst, + env=env, + ) + + if result.returncode != 0 or tmp.stat().st_size == 0: + console.print("[red]ERROR:[/red] Encryption failed") + tmp.unlink(missing_ok=True) + return None + + return tmp + + +def transfer_to_indri(encrypted_file: Path, timestamp: str) -> bool: + """SCP the encrypted file to indri and clean up old exports.""" + console.print("Transferring to indri...") + + remote_file = f"{REMOTE_DIR}/1password-export-{timestamp}.age" + + # Ensure remote directory exists + subprocess.run( + ["ssh", "indri", f"mkdir -p {REMOTE_DIR}"], + capture_output=True, + ) + + # Transfer + result = subprocess.run( + ["scp", "-q", str(encrypted_file), f"indri:{remote_file}"], + capture_output=True, + ) + if result.returncode != 0: + console.print("[red]ERROR:[/red] SCP to indri failed") + console.print(f"Encrypted file preserved at: {encrypted_file}") + return False + + # Clean up old exports (keep last N) + subprocess.run( + [ + "ssh", "indri", + f"ls -t {REMOTE_DIR}/1password-export-*.age 2>/dev/null" + f" | tail -n +{KEEP_RECENT + 1} | xargs rm -f 2>/dev/null", + ], + capture_output=True, + ) + + # Report size + size_result = subprocess.run( + ["ssh", "indri", f"du -h '{remote_file}'"], + capture_output=True, + text=True, + ) + size = size_result.stdout.split()[0] if size_result.returncode == 0 else "?" + + console.print() + console.print(f"[green]Backup complete:[/green] indri:{remote_file} ({size})") + return True + + +def main() -> int: + if not check_dependencies(): + return 1 + + export_path = get_export_path(sys.argv[1] if len(sys.argv) > 1 else None) + if not export_path: + return 1 + + file_size = f"{export_path.stat().st_size / 1024 / 1024:.1f} MB" + console.print(f"Source: {export_path} ({file_size})") + + passphrase = fetch_credentials() + if not passphrase: + return 1 + + encrypted_file = encrypt(export_path, passphrase) + # Clear passphrase from local scope as soon as possible + del passphrase + if not encrypted_file: + return 1 + + # Delete plaintext .1pux + export_path.unlink() + console.print(f"Deleted plaintext export: {export_path}") + + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + if not transfer_to_indri(encrypted_file, timestamp): + return 1 + + # Clean up local temp file + encrypted_file.unlink(missing_ok=True) + + console.print() + console.print("[bold]DISASTER RECOVERY:[/bold]") + console.print(f" 1. Restore borgmatic archive containing {REMOTE_DIR}/") + console.print(" 2. Retrieve Emergency Kit from safety deposit box") + console.print(" 3. age --decrypt .age > export.1pux") + console.print(" 4. Passphrase: {master_password}:{secret_key}") + console.print(" 5. Open export.1pux with 1Password or unzip to inspect") + return 0 + + +if __name__ == "__main__": + sys.exit(main())