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:
2026-05-20 15:55:53 +02:00
commit 1f1889629f
4 changed files with 252 additions and 0 deletions
+73
View File
@@ -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<your-public-key> 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.
+170
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
bech32>=1.2.0
cryptography>=42.0
mnemonic>=0.21
Executable
+6
View File
@@ -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 <command>"