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)"
"""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.
Generates a temporary age key pair, encrypts the .1pux with the public key,
then encrypts the private key with openssl using your 1Password master password
and secret key as the passphrase (via fd, never exposed in env or ps). Both
files are SCPed to indri for borgmatic pickup.
Usage:
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.
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
3. age --decrypt <file>.age > export.1pux
4. Passphrase: {master_password}:{secret_key}
3. openssl enc -d -aes-256-cbc -pbkdf2 < backup.key.enc > key.txt
(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
"""
@ -48,7 +50,7 @@ console = Console()
def check_dependencies() -> bool:
"""Verify required CLI tools are installed."""
ok = True
for cmd in ("op", "age", "ssh", "scp"):
for cmd in ("op", "age", "openssl", "ssh", "scp"):
if not shutil.which(cmd):
console.print(f"[red]ERROR:[/red] {cmd} is required but not installed")
if cmd == "age":
@ -118,36 +120,84 @@ def _op_field(field: str) -> str | 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...")
def encrypt(export_path: Path, passphrase: str, tmpdir: Path) -> tuple[Path, Path] | None:
"""Encrypt the .1pux with a temporary age key pair.
fd, tmp_path = tempfile.mkstemp(suffix=".age")
os.close(fd)
tmp = Path(tmp_path)
Returns (encrypted_export, encrypted_key) paths on success, None on failure.
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)
1. age-keygen generates a fresh key pair
2. age encrypts the .1pux with the public key (non-interactive)
3. openssl encrypts the private key with the passphrase via fd (no env/ps leak)
"""
console.print("Generating age key pair...")
key_path = tmpdir / "key.txt"
result = subprocess.run(
["age-keygen", "-o", str(key_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
console.print("[red]ERROR:[/red] age-keygen failed")
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:
"""SCP the encrypted file to indri and clean up old exports."""
def transfer_to_indri(encrypted_export: Path, encrypted_key: Path, timestamp: str) -> bool:
"""SCP both encrypted files to indri and clean up old exports."""
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
subprocess.run(
@ -155,17 +205,32 @@ def transfer_to_indri(encrypted_file: Path, timestamp: str) -> bool:
capture_output=True,
)
# Transfer
# Transfer both files
result = subprocess.run(
["scp", "-q", str(encrypted_file), f"indri:{remote_file}"],
[
"scp", "-q",
str(encrypted_export), f"indri:{remote_export}",
],
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}")
console.print("[red]ERROR:[/red] SCP of encrypted export failed")
console.print(f"Encrypted files preserved in: {encrypted_export.parent}")
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(
[
"ssh", "indri",
@ -174,17 +239,25 @@ def transfer_to_indri(encrypted_file: Path, timestamp: str) -> bool:
],
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
size_result = subprocess.run(
["ssh", "indri", f"du -h '{remote_file}'"],
["ssh", "indri", f"du -h '{remote_export}'"],
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})")
console.print(f"[green]Backup complete:[/green] indri:{remote_export} ({size})")
return True
@ -203,29 +276,29 @@ def main() -> int:
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
with tempfile.TemporaryDirectory() as tmpdir:
result = encrypt(export_path, passphrase, Path(tmpdir))
del passphrase
if not result:
return 1
# Delete plaintext .1pux
export_path.unlink()
console.print(f"Deleted plaintext export: {export_path}")
encrypted_export, encrypted_key = result
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
if not transfer_to_indri(encrypted_file, timestamp):
return 1
# Delete plaintext .1pux
export_path.unlink()
console.print(f"Deleted plaintext export: {export_path}")
# Clean up local temp file
encrypted_file.unlink(missing_ok=True)
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
if not transfer_to_indri(encrypted_export, encrypted_key, timestamp):
return 1
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(f" 3. openssl enc -d -aes-256-cbc -pbkdf2 < ...key.enc > key.txt")
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")
return 0