From b857aa3afb72b4e0553758a9be376405a360f35d Mon Sep 17 00:00:00 2001 From: Jakob Husu Date: Wed, 20 May 2026 11:31:36 +0200 Subject: [PATCH] Add SSH key generator with BIP39 mnemonic backup Generates Ed25519 SSH keys and encodes the private key seed as 24 BIP39 words for safe offline storage. Keys can be fully recovered from the mnemonic alone. --- keygen.py | 196 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + setup.sh | 6 ++ 3 files changed, 204 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..e84e7e4 --- /dev/null +++ b/keygen.py @@ -0,0 +1,196 @@ +#!/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, 32-byte Ed25519 seed + + +def entropy_to_private_key(entropy: bytes) -> Ed25519PrivateKey: + if len(entropy) != 32: + raise ValueError(f"Expected 32 bytes of entropy, got {len(entropy)}") + return Ed25519PrivateKey.from_private_bytes(entropy) + + +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.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 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 = entropy_to_private_key(entropy) + + passphrase = ask_passphrase(confirm=True) if args.passphrase else None + + output = 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 = entropy_to_private_key(entropy) + + passphrase = ask_passphrase(confirm=True) if args.passphrase else None + + output = 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="id_ed25519", + metavar="PATH", + help="output path for the private key (default: 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", + ).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 "