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:
@@ -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="*",
|
||||||
|
|||||||
Reference in New Issue
Block a user