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.
This commit is contained in:
2026-05-20 11:31:36 +02:00
commit b857aa3afb
3 changed files with 204 additions and 0 deletions
+196
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
cryptography>=42.0
mnemonic>=0.21
Executable
+6
View File
@@ -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 <command>"