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,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.
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
bech32>=1.2.0
|
||||||
|
cryptography>=42.0
|
||||||
|
mnemonic>=0.21
|
||||||
Reference in New Issue
Block a user