Add age-seed-keygen CLI tool
Generates and recovers age X25519 identity keys from a BIP39 24-word mnemonic. Uses 256 bits of entropy mapped directly to an X25519 private key, encoded in the standard age identity file format (AGE-SECRET-KEY-1…). Commands: generate — create a new age identity and print the 24-word mnemonic recover — reconstruct the exact same identity from the mnemonic Dependencies: bech32, cryptography, mnemonic. Setup via setup.sh.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
age key generator with BIP39 mnemonic backup.
|
||||
|
||||
generate — creates a new X25519 age identity and prints 24 recovery words
|
||||
recover — re-creates the exact same age identity from those 24 words
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from mnemonic import Mnemonic
|
||||
import bech32 as bech32lib
|
||||
|
||||
|
||||
ENTROPY_BITS = 256 # → 24 BIP39 words
|
||||
|
||||
|
||||
def encode_identity(private_bytes: bytes) -> str:
|
||||
data = bech32lib.convertbits(list(private_bytes), 8, 5)
|
||||
return bech32lib.bech32_encode("age-secret-key-", data).upper()
|
||||
|
||||
|
||||
def encode_recipient(public_bytes: bytes) -> str:
|
||||
data = bech32lib.convertbits(list(public_bytes), 8, 5)
|
||||
return bech32lib.bech32_encode("age", data)
|
||||
|
||||
|
||||
def derive_age_keys(entropy: bytes) -> tuple[str, str]:
|
||||
private_key = X25519PrivateKey.from_private_bytes(entropy)
|
||||
identity = encode_identity(private_key.private_bytes_raw())
|
||||
recipient = encode_recipient(private_key.public_key().public_bytes_raw())
|
||||
return identity, recipient
|
||||
|
||||
|
||||
def save_identity_file(identity: str, recipient: str, path: Path) -> None:
|
||||
created = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
content = f"# created: {created}\n# public key: {recipient}\n{identity}\n"
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def format_mnemonic(words: str) -> str:
|
||||
word_list = words.split()
|
||||
lines = []
|
||||
for i, word in enumerate(word_list, 1):
|
||||
lines.append(f" {i:2d}. {word}")
|
||||
if i % 6 == 0 and i < len(word_list):
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def resolve_output_path(requested: Path) -> Path:
|
||||
path = requested
|
||||
while path.exists():
|
||||
print(f"File already exists: {path}")
|
||||
print(" [o] Overwrite [r] Rename [q] Quit")
|
||||
choice = input("Choice: ").strip().lower()
|
||||
if choice == "o":
|
||||
break
|
||||
elif choice == "r":
|
||||
new_name = input(f"New filename (in {path.parent}): ").strip()
|
||||
if not new_name:
|
||||
print("No name entered, try again.")
|
||||
continue
|
||||
path = path.parent / new_name
|
||||
elif choice == "q":
|
||||
print("Aborted.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("Please enter o, r, or q.")
|
||||
return path
|
||||
|
||||
|
||||
def cmd_generate(args: argparse.Namespace) -> None:
|
||||
mnemo = Mnemonic("english")
|
||||
entropy = os.urandom(ENTROPY_BITS // 8)
|
||||
words = mnemo.to_mnemonic(entropy)
|
||||
identity, recipient = derive_age_keys(entropy)
|
||||
|
||||
output = resolve_output_path(Path(args.output))
|
||||
save_identity_file(identity, recipient, output)
|
||||
|
||||
print(f"\nGenerated X25519 age identity")
|
||||
print(f" Identity file : {output}")
|
||||
print(f" Public key : {recipient}")
|
||||
|
||||
print(f"\nYour 24-word recovery mnemonic — write these down and store offline:\n")
|
||||
print(format_mnemonic(words))
|
||||
print("\nThese words reconstruct your exact age identity. Keep them secret.")
|
||||
|
||||
|
||||
def cmd_recover(args: argparse.Namespace) -> None:
|
||||
mnemo = Mnemonic("english")
|
||||
|
||||
if args.words:
|
||||
raw = " ".join(args.words)
|
||||
else:
|
||||
print("Enter your 24 recovery words (space-separated), then press Enter:")
|
||||
raw = input().strip()
|
||||
|
||||
words = " ".join(raw.lower().split())
|
||||
|
||||
if not mnemo.check(words):
|
||||
print("Error: invalid mnemonic — check spelling or word count.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
entropy = bytes(mnemo.to_entropy(words))
|
||||
identity, recipient = derive_age_keys(entropy)
|
||||
|
||||
output = resolve_output_path(Path(args.output))
|
||||
save_identity_file(identity, recipient, output)
|
||||
|
||||
print(f"\nRecovered X25519 age identity")
|
||||
print(f" Identity file : {output}")
|
||||
print(f" Public key : {recipient}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate or recover age X25519 identity keys using a BIP39 mnemonic seed",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
||||
|
||||
shared = argparse.ArgumentParser(add_help=False)
|
||||
shared.add_argument(
|
||||
"-o", "--output",
|
||||
default=str(Path.home() / ".config" / "age" / "key.txt"),
|
||||
metavar="PATH",
|
||||
help="output path for the identity file (default: ~/.config/age/key.txt)",
|
||||
)
|
||||
|
||||
sub.add_parser(
|
||||
"generate",
|
||||
aliases=["gen"],
|
||||
parents=[shared],
|
||||
help="generate a new age identity and print its 24-word mnemonic",
|
||||
)
|
||||
sub.add_parser(
|
||||
"recover",
|
||||
aliases=["rec"],
|
||||
parents=[shared],
|
||||
help="recover an age identity from a 24-word mnemonic",
|
||||
).add_argument(
|
||||
"words",
|
||||
nargs="*",
|
||||
metavar="WORD",
|
||||
help="the 24 mnemonic words (omit to be prompted)",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command in ("generate", "gen"):
|
||||
cmd_generate(args)
|
||||
elif args.command in ("recover", "rec"):
|
||||
cmd_recover(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user