blumeops/mise-tasks/op-backup
Erich Blume 0bbdd3602f Add op-backup mise task for encrypted 1Password disaster recovery backups
Encrypts a .1pux export from the 1Password desktop app with age using the
master password + secret key as the passphrase, then SCPs to indri where
borgmatic picks it up. Provides double encryption (age + borg repokey) and
recovery requires only the Emergency Kit from the safety deposit box.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:29:25 -08:00

234 lines
7.4 KiB
Text
Executable file

#!/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 <file>.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 <file>.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())