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.
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="*",