diff --git a/mise-tasks/op-backup b/mise-tasks/op-backup index d9841b9..48b8a9a 100755 --- a/mise-tasks/op-backup +++ b/mise-tasks/op-backup @@ -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 .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 .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