Add op-backup mise task for encrypted 1Password disaster recovery #136
1 changed files with 119 additions and 46 deletions
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>
commit
884c0b232a
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue