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.
This commit is contained in:
2026-05-20 11:47:30 +02:00
parent 14b5217e49
commit f76a773433
+38 -17
View File
@@ -2,28 +2,35 @@
""" """
SSH key generator with BIP39 mnemonic backup. SSH key generator with BIP39 mnemonic backup.
generate — creates a new Ed25519 SSH key pair and prints 24 recovery 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 recover — re-creates the exact same key pair from those 24 words + timestamp
""" """
import argparse import argparse
import getpass import getpass
import os import os
import sys import sys
from datetime import datetime, timezone
from pathlib import Path 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.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from mnemonic import Mnemonic 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: def derive_seed(entropy: bytes, timestamp: str) -> bytes:
if len(entropy) != 32: """Derive 32-byte Ed25519 seed from mnemonic entropy + timestamp."""
raise ValueError(f"Expected 32 bytes of entropy, got {len(entropy)}") return HKDF(
return Ed25519PrivateKey.from_private_bytes(entropy) algorithm=hashes.SHA256(),
length=32,
salt=timestamp.encode(),
info=b"ssh-seed-keygen v1",
).derive(entropy)
def save_keypair( def save_keypair(
@@ -73,7 +80,6 @@ def format_mnemonic(words: str) -> str:
def resolve_output_path(requested: Path) -> Path: def resolve_output_path(requested: Path) -> Path:
"""Return the path to write to, prompting if the file already exists."""
path = requested path = requested
while path.exists(): while path.exists():
print(f"File already exists: {path}") print(f"File already exists: {path}")
@@ -107,12 +113,25 @@ def ask_passphrase(confirm: bool = True) -> bytes | None:
return passphrase.encode() 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: def cmd_generate(args: argparse.Namespace) -> None:
mnemo = Mnemonic("english") mnemo = Mnemonic("english")
entropy = os.urandom(ENTROPY_BITS // 8) entropy = os.urandom(ENTROPY_BITS // 8)
words = mnemo.to_mnemonic(entropy) 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 passphrase = ask_passphrase(confirm=True) if args.passphrase else None
@@ -125,11 +144,10 @@ def cmd_generate(args: argparse.Namespace) -> None:
if passphrase: if passphrase:
print(" Passphrase : set") 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(format_mnemonic(words))
print( print("\nKeep these secret. Both the words and the timestamp are required to recover.")
"\nThese words reconstruct your exact private key. Keep them secret."
)
def cmd_recover(args: argparse.Namespace) -> None: 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) print("Error: invalid mnemonic — check spelling or word count.", file=sys.stderr)
sys.exit(1) sys.exit(1)
timestamp = ask_timestamp()
entropy = bytes(mnemo.to_entropy(words)) 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 passphrase = ask_passphrase(confirm=True) if args.passphrase else None
@@ -189,13 +210,13 @@ def build_parser() -> argparse.ArgumentParser:
"generate", "generate",
aliases=["gen"], aliases=["gen"],
parents=[shared], 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( sub.add_parser(
"recover", "recover",
aliases=["rec"], aliases=["rec"],
parents=[shared], 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( ).add_argument(
"words", "words",
nargs="*", nargs="*",