Files
ssh-seed-keygen/keygen.py
T
jakobhusu f76a773433 Add generation timestamp as a second factor for key derivation
Key seed is now derived via HKDF(entropy, salt=timestamp), so recovery
requires both the 24 mnemonic words and the exact UTC timestamp shown
at generation time. Wrong timestamp produces a completely different key.
2026-05-20 11:47:30 +02:00

242 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
SSH key generator with BIP39 mnemonic backup.
generate — creates a new Ed25519 SSH key pair and prints 24 recovery words + timestamp
recover — re-creates the exact same key pair from those 24 words + timestamp
"""
import argparse
import getpass
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from mnemonic import Mnemonic
ENTROPY_BITS = 256 # → 24 BIP39 words
TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S UTC"
def derive_seed(entropy: bytes, timestamp: str) -> bytes:
"""Derive 32-byte Ed25519 seed from mnemonic entropy + timestamp."""
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=timestamp.encode(),
info=b"ssh-seed-keygen v1",
).derive(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.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 ask_timestamp() -> str:
print(f"Generation timestamp (format: YYYY-MM-DD HH:MM:SS UTC):")
raw = input("Timestamp: ").strip()
try:
datetime.strptime(raw, TIMESTAMP_FORMAT)
except ValueError:
print(f"Error: timestamp must match format '{TIMESTAMP_FORMAT}'", file=sys.stderr)
sys.exit(1)
return raw
def cmd_generate(args: argparse.Namespace) -> None:
mnemo = Mnemonic("english")
entropy = os.urandom(ENTROPY_BITS // 8)
words = mnemo.to_mnemonic(entropy)
timestamp = datetime.now(timezone.utc).strftime(TIMESTAMP_FORMAT)
seed = derive_seed(entropy, timestamp)
private_key = Ed25519PrivateKey.from_private_bytes(seed)
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"\nWrite ALL of the following down — you need both to recover your key:\n")
print(f" Timestamp : {timestamp}\n")
print(format_mnemonic(words))
print("\nKeep these secret. Both the words and the timestamp are required to recover.")
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)
timestamp = ask_timestamp()
entropy = bytes(mnemo.to_entropy(words))
seed = derive_seed(entropy, timestamp)
private_key = Ed25519PrivateKey.from_private_bytes(seed)
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 + timestamp",
)
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()