Add op-backup mise task for encrypted 1Password disaster recovery #136

Merged
eblume merged 2 commits from feature/op-backup into main 2026-02-09 20:37:40 -08:00
Showing only changes of commit 884c0b232a - Show all commits

Rewrite op-backup to use age key pair + openssl fd passphrase

Replace the pty-based age passphrase approach (which hung in non-tty
contexts) with a two-layer scheme: age-keygen generates a fresh key pair,
age encrypts the .1pux with the public key (non-interactive), then openssl
encrypts the age private key with the 1Password credentials passed via
fd (never exposed in env vars or ps output).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Erich Blume 2026-02-09 20:35:43 -08:00

View file

@ -7,9 +7,10 @@
#USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)" #USAGE arg "[export_path]" help="Path to .1pux export file (prompted if omitted)"
"""Encrypt a 1Password export and transfer to indri for borgmatic backup. """Encrypt a 1Password export and transfer to indri for borgmatic backup.
Encrypts a .1pux file with age using your 1Password master password and secret Generates a temporary age key pair, encrypts the .1pux with the public key,
key as the passphrase, SCPs the result to indri for borgmatic pickup, then then encrypts the private key with openssl using your 1Password master password
deletes the plaintext .1pux file. and secret key as the passphrase (via fd, never exposed in env or ps). Both
files are SCPed to indri for borgmatic pickup.
Usage: Usage:
mise run op-backup [path/to/export.1pux] mise run op-backup [path/to/export.1pux]
@ -17,10 +18,11 @@ Usage:
If no path is given, prompts you to export from the 1Password desktop app first. If no path is given, prompts you to export from the 1Password desktop app first.
DISASTER RECOVERY: DISASTER RECOVERY:
1. Restore borgmatic archive to get the .age file 1. Restore borgmatic archive to get the .age and .key.enc files
2. Retrieve Emergency Kit from safety deposit box 2. Retrieve Emergency Kit from safety deposit box
3. age --decrypt <file>.age > export.1pux 3. openssl enc -d -aes-256-cbc -pbkdf2 < backup.key.enc > key.txt
4. Passphrase: {master_password}:{secret_key} (passphrase: {master_password}:{secret_key})
4. age -d -i key.txt < backup.age > export.1pux
5. Open export.1pux with 1Password or unzip to inspect 5. Open export.1pux with 1Password or unzip to inspect
""" """
@ -48,7 +50,7 @@ console = Console()
def check_dependencies() -> bool: def check_dependencies() -> bool:
"""Verify required CLI tools are installed.""" """Verify required CLI tools are installed."""
ok = True ok = True
for cmd in ("op", "age", "ssh", "scp"): for cmd in ("op", "age", "openssl", "ssh", "scp"):
if not shutil.which(cmd): if not shutil.which(cmd):
console.print(f"[red]ERROR:[/red] {cmd} is required but not installed") console.print(f"[red]ERROR:[/red] {cmd} is required but not installed")
if cmd == "age": if cmd == "age":
@ -118,36 +120,84 @@ def _op_field(field: str) -> str | None:
return result.stdout.strip() or None return result.stdout.strip() or None
def encrypt(export_path: Path, passphrase: str) -> Path | None: def encrypt(export_path: Path, passphrase: str, tmpdir: Path) -> tuple[Path, Path] | None:
"""Encrypt the .1pux file with age, returning the encrypted temp file path.""" """Encrypt the .1pux with a temporary age key pair.
console.print("Encrypting...")
fd, tmp_path = tempfile.mkstemp(suffix=".age") Returns (encrypted_export, encrypted_key) paths on success, None on failure.
os.close(fd)
tmp = Path(tmp_path)
env = {**os.environ, "AGE_PASSPHRASE": passphrase} 1. age-keygen generates a fresh key pair
with open(export_path, "rb") as src, open(tmp, "wb") as dst: 2. age encrypts the .1pux with the public key (non-interactive)
result = subprocess.run( 3. openssl encrypts the private key with the passphrase via fd (no env/ps leak)
["age", "--encrypt", "--passphrase", "--armor"], """
stdin=src, console.print("Generating age key pair...")
stdout=dst, key_path = tmpdir / "key.txt"
env=env, result = subprocess.run(
) ["age-keygen", "-o", str(key_path)],
capture_output=True,
if result.returncode != 0 or tmp.stat().st_size == 0: text=True,
console.print("[red]ERROR:[/red] Encryption failed") )
tmp.unlink(missing_ok=True) if result.returncode != 0:
console.print("[red]ERROR:[/red] age-keygen failed")
return None return None
return tmp # Extract public key from stderr (age-keygen prints "Public key: age1...")
pubkey = None
for line in result.stderr.splitlines():
if line.startswith("Public key:"):
pubkey = line.split(": ", 1)[1].strip()
break
if not pubkey:
console.print("[red]ERROR:[/red] Could not parse public key from age-keygen")
return None
# Encrypt the .1pux with the public key (non-interactive)
console.print("Encrypting export...")
encrypted_export = tmpdir / "export.age"
with open(export_path, "rb") as src, open(encrypted_export, "wb") as dst:
result = subprocess.run(
["age", "-e", "-r", pubkey, "--armor"],
stdin=src,
stdout=dst,
)
if result.returncode != 0 or encrypted_export.stat().st_size == 0:
console.print("[red]ERROR:[/red] age encryption failed")
return None
# Encrypt the private key with openssl, passphrase via fd (not env or cli arg)
console.print("Encrypting age key with passphrase...")
encrypted_key = tmpdir / "key.enc"
pass_read_fd, pass_write_fd = os.pipe()
os.write(pass_write_fd, passphrase.encode())
os.close(pass_write_fd)
with open(key_path, "rb") as src, open(encrypted_key, "wb") as dst:
result = subprocess.run(
[
"openssl", "enc", "-aes-256-cbc", "-pbkdf2",
"-pass", f"fd:{pass_read_fd}",
],
stdin=src,
stdout=dst,
pass_fds=(pass_read_fd,),
)
os.close(pass_read_fd)
if result.returncode != 0 or encrypted_key.stat().st_size == 0:
console.print("[red]ERROR:[/red] openssl encryption of key failed")
return None
# Shred the plaintext key immediately
key_path.unlink()
return encrypted_export, encrypted_key
def transfer_to_indri(encrypted_file: Path, timestamp: str) -> bool: def transfer_to_indri(encrypted_export: Path, encrypted_key: Path, timestamp: str) -> bool:
"""SCP the encrypted file to indri and clean up old exports.""" """SCP both encrypted files to indri and clean up old exports."""
console.print("Transferring to indri...") console.print("Transferring to indri...")
remote_file = f"{REMOTE_DIR}/1password-export-{timestamp}.age" remote_export = f"{REMOTE_DIR}/1password-export-{timestamp}.age"
remote_key = f"{REMOTE_DIR}/1password-export-{timestamp}.key.enc"
# Ensure remote directory exists # Ensure remote directory exists
subprocess.run( subprocess.run(
@ -155,17 +205,32 @@ def transfer_to_indri(encrypted_file: Path, timestamp: str) -> bool:
capture_output=True, capture_output=True,
) )
# Transfer # Transfer both files
result = subprocess.run( result = subprocess.run(
["scp", "-q", str(encrypted_file), f"indri:{remote_file}"], [
"scp", "-q",
str(encrypted_export), f"indri:{remote_export}",
],
capture_output=True, capture_output=True,
) )
if result.returncode != 0: if result.returncode != 0:
console.print("[red]ERROR:[/red] SCP to indri failed") console.print("[red]ERROR:[/red] SCP of encrypted export failed")
console.print(f"Encrypted file preserved at: {encrypted_file}") console.print(f"Encrypted files preserved in: {encrypted_export.parent}")
return False return False
# Clean up old exports (keep last N) result = subprocess.run(
[
"scp", "-q",
str(encrypted_key), f"indri:{remote_key}",
],
capture_output=True,
)
if result.returncode != 0:
console.print("[red]ERROR:[/red] SCP of encrypted key failed")
console.print(f"Encrypted files preserved in: {encrypted_export.parent}")
return False
# Clean up old exports (keep last N sets — each set is a .age + .key.enc)
subprocess.run( subprocess.run(
[ [
"ssh", "indri", "ssh", "indri",
@ -174,17 +239,25 @@ def transfer_to_indri(encrypted_file: Path, timestamp: str) -> bool:
], ],
capture_output=True, capture_output=True,
) )
subprocess.run(
[
"ssh", "indri",
f"ls -t {REMOTE_DIR}/1password-export-*.key.enc 2>/dev/null"
f" | tail -n +{KEEP_RECENT + 1} | xargs rm -f 2>/dev/null",
],
capture_output=True,
)
# Report size # Report size
size_result = subprocess.run( size_result = subprocess.run(
["ssh", "indri", f"du -h '{remote_file}'"], ["ssh", "indri", f"du -h '{remote_export}'"],
capture_output=True, capture_output=True,
text=True, text=True,
) )
size = size_result.stdout.split()[0] if size_result.returncode == 0 else "?" size = size_result.stdout.split()[0] if size_result.returncode == 0 else "?"
console.print() console.print()
console.print(f"[green]Backup complete:[/green] indri:{remote_file} ({size})") console.print(f"[green]Backup complete:[/green] indri:{remote_export} ({size})")
return True return True
@ -203,29 +276,29 @@ def main() -> int:
if not passphrase: if not passphrase:
return 1 return 1
encrypted_file = encrypt(export_path, passphrase) with tempfile.TemporaryDirectory() as tmpdir:
# Clear passphrase from local scope as soon as possible result = encrypt(export_path, passphrase, Path(tmpdir))
del passphrase del passphrase
if not encrypted_file: if not result:
return 1 return 1
# Delete plaintext .1pux encrypted_export, encrypted_key = result
export_path.unlink()
console.print(f"Deleted plaintext export: {export_path}")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") # Delete plaintext .1pux
if not transfer_to_indri(encrypted_file, timestamp): export_path.unlink()
return 1 console.print(f"Deleted plaintext export: {export_path}")
# Clean up local temp file timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
encrypted_file.unlink(missing_ok=True) if not transfer_to_indri(encrypted_export, encrypted_key, timestamp):
return 1
console.print() console.print()
console.print("[bold]DISASTER RECOVERY:[/bold]") console.print("[bold]DISASTER RECOVERY:[/bold]")
console.print(f" 1. Restore borgmatic archive containing {REMOTE_DIR}/") console.print(f" 1. Restore borgmatic archive containing {REMOTE_DIR}/")
console.print(" 2. Retrieve Emergency Kit from safety deposit box") console.print(" 2. Retrieve Emergency Kit from safety deposit box")
console.print(" 3. age --decrypt <file>.age > export.1pux") console.print(f" 3. openssl enc -d -aes-256-cbc -pbkdf2 < ...key.enc > key.txt")
console.print(" 4. Passphrase: {master_password}:{secret_key}") console.print(" Passphrase: {master_password}:{secret_key}")
console.print(f" 4. age -d -i key.txt < ...age > export.1pux")
console.print(" 5. Open export.1pux with 1Password or unzip to inspect") console.print(" 5. Open export.1pux with 1Password or unzip to inspect")
return 0 return 0