diff --git a/keygen.py b/keygen.py index d3274c5..2d09bbb 100644 --- a/keygen.py +++ b/keygen.py @@ -2,28 +2,35 @@ """ 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 +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 serialization +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, 32-byte Ed25519 seed +ENTROPY_BITS = 256 # → 24 BIP39 words +TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S UTC" -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 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( @@ -73,7 +80,6 @@ def format_mnemonic(words: str) -> str: def resolve_output_path(requested: Path) -> Path: - """Return the path to write to, prompting if the file already exists.""" path = requested while path.exists(): print(f"File already exists: {path}") @@ -107,12 +113,25 @@ def ask_passphrase(confirm: bool = True) -> bytes | None: 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) - private_key = entropy_to_private_key(entropy) + seed = derive_seed(entropy, timestamp) + private_key = Ed25519PrivateKey.from_private_bytes(seed) passphrase = ask_passphrase(confirm=True) if args.passphrase else None @@ -125,11 +144,10 @@ def cmd_generate(args: argparse.Namespace) -> None: if passphrase: print(" Passphrase : set") - print(f"\nYour 24-word recovery mnemonic — write these down and store offline:\n") + 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( - "\nThese words reconstruct your exact private key. Keep them secret." - ) + print("\nKeep these secret. Both the words and the timestamp are required to recover.") def cmd_recover(args: argparse.Namespace) -> None: @@ -147,8 +165,11 @@ def cmd_recover(args: argparse.Namespace) -> None: print("Error: invalid mnemonic — check spelling or word count.", file=sys.stderr) sys.exit(1) + timestamp = ask_timestamp() + entropy = bytes(mnemo.to_entropy(words)) - private_key = entropy_to_private_key(entropy) + seed = derive_seed(entropy, timestamp) + private_key = Ed25519PrivateKey.from_private_bytes(seed) passphrase = ask_passphrase(confirm=True) if args.passphrase else None @@ -189,13 +210,13 @@ def build_parser() -> argparse.ArgumentParser: "generate", aliases=["gen"], parents=[shared], - help="generate a new SSH key pair and print its 24-word mnemonic", + 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", + help="recover an SSH key pair from a 24-word mnemonic + timestamp", ).add_argument( "words", nargs="*",