From 1a2d2cd23a8764a15461a44e7bf19b689c6f338b Mon Sep 17 00:00:00 2001 From: Jakob Husu Date: Wed, 20 May 2026 11:58:37 +0200 Subject: [PATCH] Add SSH key generator with BIP39 mnemonic backup Generates Ed25519 SSH keys encoded as 24 BIP39 words for offline backup. Keys can be fully recovered from the mnemonic alone. --- keygen.py | 210 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + setup.sh | 6 ++ 3 files changed, 218 insertions(+) create mode 100644 keygen.py create mode 100644 requirements.txt create mode 100755 setup.sh diff --git a/keygen.py b/keygen.py new file mode 100644 index 0000000..4855883 --- /dev/null +++ b/keygen.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +SSH key generator with BIP39 mnemonic backup. + +generate — creates a new Ed25519 SSH key pair and prints 24 recovery words +recover — re-creates the exact same key pair from those 24 words +""" + +import argparse +import getpass +import os +import sys +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from mnemonic import Mnemonic + + +ENTROPY_BITS = 256 # → 24 BIP39 words + + +def save_keypair( + private_key: Ed25519PrivateKey, + path: Path, + comment: str, + passphrase: bytes | None, +) -> tuple[Path, Path]: + encryption = ( + serialization.BestAvailableEncryption(passphrase) + if passphrase + else serialization.NoEncryption() + ) + + private_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.OpenSSH, + encryption_algorithm=encryption, + ) + + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + if comment: + public_bytes = public_bytes + b" " + comment.encode() + + pub_path = path.with_suffix("").parent / (path.name + ".pub") + + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + path.write_bytes(private_bytes) + path.chmod(0o600) + pub_path.write_bytes(public_bytes + b"\n") + pub_path.chmod(0o644) + + return path, pub_path + + +def format_mnemonic(words: str) -> str: + word_list = words.split() + lines = [] + for i, word in enumerate(word_list, 1): + lines.append(f" {i:2d}. {word}") + if i % 6 == 0 and i < len(word_list): + lines.append("") + return "\n".join(lines) + + +def resolve_output_path(requested: Path) -> Path: + path = requested + while path.exists(): + print(f"File already exists: {path}") + print(" [o] Overwrite [r] Rename [q] Quit") + choice = input("Choice: ").strip().lower() + if choice == "o": + break + elif choice == "r": + new_name = input(f"New filename (in {path.parent}): ").strip() + if not new_name: + print("No name entered, try again.") + continue + path = path.parent / new_name + elif choice == "q": + print("Aborted.") + sys.exit(0) + else: + print("Please enter o, r, or q.") + return path + + +def ask_passphrase(confirm: bool = True) -> bytes | None: + passphrase = getpass.getpass("Key passphrase (leave blank for none): ") + if not passphrase: + return None + if confirm: + again = getpass.getpass("Confirm passphrase: ") + if passphrase != again: + print("Error: passphrases do not match.", file=sys.stderr) + sys.exit(1) + return passphrase.encode() + + +def cmd_generate(args: argparse.Namespace) -> None: + mnemo = Mnemonic("english") + entropy = os.urandom(ENTROPY_BITS // 8) + words = mnemo.to_mnemonic(entropy) + private_key = Ed25519PrivateKey.from_private_bytes(entropy) + + passphrase = ask_passphrase(confirm=True) if args.passphrase else None + + output = resolve_output_path(Path(args.output)) + key_path, pub_path = save_keypair(private_key, output, args.comment, passphrase) + + print(f"\nGenerated Ed25519 SSH key pair") + print(f" Private key : {key_path}") + print(f" Public key : {pub_path}") + if passphrase: + print(" Passphrase : set") + + print(f"\nYour 24-word recovery mnemonic — write these down and store offline:\n") + print(format_mnemonic(words)) + print("\nThese words reconstruct your exact private key. Keep them secret.") + + +def cmd_recover(args: argparse.Namespace) -> None: + mnemo = Mnemonic("english") + + if args.words: + raw = " ".join(args.words) + else: + print("Enter your 24 recovery words (space-separated), then press Enter:") + raw = input().strip() + + words = " ".join(raw.lower().split()) + + if not mnemo.check(words): + print("Error: invalid mnemonic — check spelling or word count.", file=sys.stderr) + sys.exit(1) + + entropy = bytes(mnemo.to_entropy(words)) + private_key = Ed25519PrivateKey.from_private_bytes(entropy) + + passphrase = ask_passphrase(confirm=True) if args.passphrase else None + + output = resolve_output_path(Path(args.output)) + key_path, pub_path = save_keypair(private_key, output, args.comment, passphrase) + + print(f"\nRecovered Ed25519 SSH key pair") + print(f" Private key : {key_path}") + print(f" Public key : {pub_path}") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Generate or recover Ed25519 SSH keys using a BIP39 mnemonic seed", + ) + sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + + shared = argparse.ArgumentParser(add_help=False) + shared.add_argument( + "-o", "--output", + default=str(Path.home() / ".ssh" / "id_ed25519"), + metavar="PATH", + help="output path for the private key (default: ~/.ssh/id_ed25519)", + ) + shared.add_argument( + "-C", "--comment", + default="", + metavar="TEXT", + help="comment embedded in the public key (e.g. your email)", + ) + shared.add_argument( + "-p", "--passphrase", + action="store_true", + help="encrypt the private key file with a passphrase", + ) + + sub.add_parser( + "generate", + aliases=["gen"], + parents=[shared], + help="generate a new SSH key pair and print its 24-word mnemonic", + ) + sub.add_parser( + "recover", + aliases=["rec"], + parents=[shared], + help="recover an SSH key pair from a 24-word mnemonic + timestamp", + ).add_argument( + "words", + nargs="*", + metavar="WORD", + help="the 24 mnemonic words (omit to be prompted)", + ) + + return parser + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + if args.command in ("generate", "gen"): + cmd_generate(args) + elif args.command in ("recover", "rec"): + cmd_recover(args) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec34706 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=42.0 +mnemonic>=0.21 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..f32d131 --- /dev/null +++ b/setup.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# One-time setup: creates a venv and installs dependencies +set -e +python3 -m venv .venv +.venv/bin/pip install -q -r requirements.txt +echo "Setup complete. Run with: .venv/bin/python keygen.py "