1f1889629f
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.
171 lines
5.1 KiB
Python
171 lines
5.1 KiB
Python
#!/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()
|