Remove timestamp — 24-word mnemonic alone is sufficient for recovery
This commit is contained in:
@@ -2,35 +2,22 @@
|
|||||||
"""
|
"""
|
||||||
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 + timestamp
|
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 + timestamp
|
recover — re-creates the exact same key pair from those 24 words
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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 hashes, serialization
|
from cryptography.hazmat.primitives import 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
|
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(
|
def save_keypair(
|
||||||
@@ -113,25 +100,11 @@ 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 = Ed25519PrivateKey.from_private_bytes(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
|
||||||
|
|
||||||
@@ -144,10 +117,9 @@ def cmd_generate(args: argparse.Namespace) -> None:
|
|||||||
if passphrase:
|
if passphrase:
|
||||||
print(" Passphrase : set")
|
print(" Passphrase : set")
|
||||||
|
|
||||||
print(f"\nWrite ALL of the following down — you need both to recover your key:\n")
|
print(f"\nYour 24-word recovery mnemonic — write these down and store offline:\n")
|
||||||
print(f" Timestamp : {timestamp}\n")
|
|
||||||
print(format_mnemonic(words))
|
print(format_mnemonic(words))
|
||||||
print("\nKeep these secret. Both the words and the timestamp are required to recover.")
|
print("\nThese words reconstruct your exact private key. Keep them secret.")
|
||||||
|
|
||||||
|
|
||||||
def cmd_recover(args: argparse.Namespace) -> None:
|
def cmd_recover(args: argparse.Namespace) -> None:
|
||||||
@@ -165,11 +137,8 @@ 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))
|
||||||
seed = derive_seed(entropy, timestamp)
|
private_key = Ed25519PrivateKey.from_private_bytes(entropy)
|
||||||
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
|
||||||
|
|
||||||
@@ -210,7 +179,7 @@ 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 + timestamp",
|
help="generate a new SSH key pair and print its 24-word mnemonic",
|
||||||
)
|
)
|
||||||
sub.add_parser(
|
sub.add_parser(
|
||||||
"recover",
|
"recover",
|
||||||
|
|||||||
Reference in New Issue
Block a user