From 1f1889629f674d372765c7fb9400f07864f222cb Mon Sep 17 00:00:00 2001 From: Jakob Husu Date: Wed, 20 May 2026 15:55:53 +0200 Subject: [PATCH] Add age-seed-keygen CLI tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 73 ++++++++++++++++++++ keygen.py | 170 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + setup.sh | 6 ++ 4 files changed, 252 insertions(+) create mode 100644 README.md create mode 100644 keygen.py create mode 100644 requirements.txt create mode 100755 setup.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..3276856 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# age-seed-keygen + +An age identity generator with a BIP39 recovery phrase. Every key comes with 24 words you can write down — lose the file, say the words, get it back. + +## The idea + +age X25519 identity keys are 32 random bytes. BIP39 is a standard for encoding random bytes as human-readable words (the same standard hardware wallets use). This tool generates 256 bits of entropy, turns it into both an age identity and a 24-word mnemonic, and gives you both. Recovery is the reverse — give back the 24 words, get back the exact same identity. + +If you already use `ssh-seed-keygen`, you can back up both an SSH key and an age identity from a single mnemonic — or keep them separate. Either way, one piece of paper is all you need. + +## Getting started + +```bash +bash setup.sh +``` + +Creates a virtualenv and installs the three dependencies. + +## Generating an identity + +```bash +.venv/bin/python keygen.py generate +``` + +Writes the identity file to `~/.config/age/key.txt` by default, then prints your 24 words. Write them down somewhere offline. + +```bash +# different output path +.venv/bin/python keygen.py generate -o ~/my-age-key.txt +``` + +## Recovering an identity + +```bash +# pass the words directly +.venv/bin/python keygen.py recover word1 word2 ... word24 + +# or run it and paste when prompted +.venv/bin/python keygen.py recover +``` + +Same `-o` flag applies if you want the recovered file somewhere other than the default path. + +## Using the identity + +The output file is a standard age identity file — it works directly with the `age` CLI: + +```bash +# encrypt a file +age -r age1 secret.txt > secret.txt.age + +# decrypt using the identity file +age --decrypt -i ~/.config/age/key.txt secret.txt.age > secret.txt +``` + +## Protecting the identity file + +The identity file is written with mode `0600`. If you want to encrypt it at rest, use age itself: + +```bash +age --passphrase -o key.txt.age ~/.config/age/key.txt +rm ~/.config/age/key.txt +``` + +Decrypt before use: + +```bash +age --decrypt key.txt.age > ~/.config/age/key.txt +``` + +## One thing to keep in mind + +The mnemonic encodes the private key directly. Anyone with those 24 words has your identity. Treat them at least as carefully as the key file itself. diff --git a/keygen.py b/keygen.py new file mode 100644 index 0000000..19eb337 --- /dev/null +++ b/keygen.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ab7784 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +bech32>=1.2.0 +cryptography>=42.0 +mnemonic>=0.21 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..f32d131 --- /dev/null +++ b/setup.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# One-time setup: creates a venv and installs dependencies +set -e +python3 -m venv .venv +.venv/bin/pip install -q -r requirements.txt +echo "Setup complete. Run with: .venv/bin/python keygen.py "