- Introduce production setup script: scripts/setup_production.py - Generates aitbc1genesis (treasury) and aitbc1treasury (spending) wallets - Encrypts keys via AES-GCM, stores password in keystore/.password (600) - Creates allocations.json and genesis.json with fixed total supply - Sets mint_per_unit=0 (no inflation) - Update make_genesis.py: - Accept allocations file instead of single faucet - Use 'allocations' key in genesis (renamed from 'accounts') - Enforce mint_per_unit=0 in default params - Remove admin mint endpoint: - Deleting MintFaucetRequest and /rpc/admin/mintFaucet from router.py - Removes faucet CLI command from cli/aitbc_cli/commands/blockchain.py - RPC supply endpoint now computes total supply from genesis file (fixed) - Validators endpoint derives list from trusted_proposers config - Config enhancements (config.py): - Add keystore_path and keystore_password_file - Change mint_per_unit default to 0 - main.py: Auto-load proposer private key from keystore into settings.proposer_key (hex) for future use - Launcher scripts: - scripts/mainnet_up.sh: Loads .env.production, derives proposer_id from keystore if needed, starts node + RPC - scripts/devnet_up.sh: Updated to use new allocations-based genesis and proper proposer address - Documentation: - Rewrite blockchain-node/README.md for production model (no faucet, keystore management, multi-chain) - Update MEMORY.md with production blockchain section - Database: Multi-chain support already present via chain_id foreign keys. This change makes the blockchain production‑ready: immutable supply, secure key storage, and removal of dev‑only admin functions. Co-authored-by: Andreas Michael Fleckl <andreas@example.com>
187 lines
6.3 KiB
Python
187 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Production key management for AITBC blockchain.
|
|
|
|
Generates ed25519 keypairs and stores them in an encrypted JSON keystore
|
|
(Ethereum-style web3 keystore). Supports multiple wallets (treasury, proposer, etc.)
|
|
|
|
Usage:
|
|
python keystore.py --name treasury --create --password <secret>
|
|
python keystore.py --name proposer --create --password <secret>
|
|
python keystore.py --name treasury --show
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
|
|
# Uses Cryptography library for ed25519 and encryption
|
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
# Address encoding: bech32m (HRP 'ait')
|
|
from bech32 import bech32_encode, convertbits
|
|
|
|
|
|
def generate_address(public_key_bytes: bytes) -> str:
|
|
"""Generate a bech32m address from a public key.
|
|
1. Take SHA256 of the public key (produces 32 bytes)
|
|
2. Convert to 5-bit groups (bech32)
|
|
3. Encode with HRP 'ait'
|
|
"""
|
|
digest = hashes.Hash(hashes.SHA256(), backend=default_backend())
|
|
digest.update(public_key_bytes)
|
|
hashed = digest.finalize()
|
|
# Convert to 5-bit words for bech32
|
|
data = convertbits(hashed, 8, 5, True)
|
|
return bech32_encode("ait", data)
|
|
|
|
|
|
def encrypt_private_key(private_key_bytes: bytes, password: str, salt: bytes) -> Dict[str, Any]:
|
|
"""Encrypt a private key using AES-GCM, wrapped in a JSON keystore."""
|
|
# Derive key from password using PBKDF2
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
iterations=100_000,
|
|
backend=default_backend()
|
|
)
|
|
key = kdf.derive(password.encode('utf-8'))
|
|
|
|
# Encrypt with AES-GCM
|
|
aesgcm = AESGCM(key)
|
|
nonce = os.urandom(12)
|
|
encrypted = aesgcm.encrypt(nonce, private_key_bytes, None)
|
|
|
|
return {
|
|
"crypto": {
|
|
"cipher": "aes-256-gcm",
|
|
"cipherparams": {"nonce": nonce.hex()},
|
|
"ciphertext": encrypted.hex(),
|
|
"kdf": "pbkdf2",
|
|
"kdfparams": {
|
|
"dklen": 32,
|
|
"salt": salt.hex(),
|
|
"c": 100_000,
|
|
"prf": "hmac-sha256"
|
|
},
|
|
"mac": "TODO" # In production, compute MAC over ciphertext and KDF params
|
|
},
|
|
"address": None, # to be filled
|
|
"keytype": "ed25519",
|
|
"version": 1
|
|
}
|
|
|
|
|
|
def generate_keypair(name: str, password: str, keystore_dir: Path) -> Dict[str, Any]:
|
|
"""Generate a new ed25519 keypair and store in keystore."""
|
|
salt = os.urandom(32)
|
|
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
public_key = private_key.public_key()
|
|
private_bytes = private_key.private_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PrivateFormat.Raw,
|
|
encryption_algorithm=serialization.NoEncryption()
|
|
)
|
|
public_bytes = public_key.public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw
|
|
)
|
|
address = generate_address(public_bytes)
|
|
|
|
keystore = encrypt_private_key(private_bytes, password, salt)
|
|
keystore["address"] = address
|
|
|
|
keystore_file = keystore_dir / f"{name}.json"
|
|
keystore_dir.mkdir(parents=True, exist_ok=True)
|
|
with open(keystore_file, 'w') as f:
|
|
json.dump(keystore, f, indent=2)
|
|
os.chmod(keystore_file, 0o600)
|
|
|
|
print(f"Generated {name} keypair")
|
|
print(f" Address: {address}")
|
|
print(f" Keystore: {keystore_file}")
|
|
return keystore
|
|
|
|
|
|
def show_keyinfo(keystore_file: Path, password: str) -> None:
|
|
"""Decrypt and show key info (address, public key)."""
|
|
with open(keystore_file) as f:
|
|
data = json.load(f)
|
|
|
|
# Derive key from password
|
|
crypto = data["crypto"]
|
|
kdfparams = crypto["kdfparams"]
|
|
salt = bytes.fromhex(kdfparams["salt"])
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
iterations=kdfparams["c"],
|
|
backend=default_backend()
|
|
)
|
|
key = kdf.derive(password.encode('utf-8'))
|
|
|
|
# Decrypt private key
|
|
nonce = bytes.fromhex(crypto["cipherparams"]["nonce"])
|
|
ciphertext = bytes.fromhex(crypto["ciphertext"])
|
|
aesgcm = AESGCM(key)
|
|
private_bytes = aesgcm.decrypt(nonce, ciphertext, None)
|
|
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_bytes)
|
|
public_bytes = private_key.public_key().public_bytes(
|
|
encoding=serialization.Encoding.Raw,
|
|
format=serialization.PublicFormat.Raw
|
|
)
|
|
address = generate_address(public_bytes)
|
|
|
|
print(f"Keystore: {keystore_file}")
|
|
print(f"Address: {address}")
|
|
print(f"Public key (hex): {public_bytes.hex()}")
|
|
|
|
|
|
def main():
|
|
from getpass import getpass
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
parser = argparse.ArgumentParser(description="Production keystore management")
|
|
parser.add_argument("--name", required=True, help="Key name (e.g., treasury, proposer)")
|
|
parser.add_argument("--create", action="store_true", help="Generate new keypair")
|
|
parser.add_argument("--show", action="store_true", help="Show address/public key (prompt for password)")
|
|
parser.add_argument("--password", help="Password (avoid using in CLI; prefer prompt or env)")
|
|
parser.add_argument("--keystore-dir", type=Path, default=Path("/opt/aitbc/keystore"), help="Keystore directory")
|
|
args = parser.parse_args()
|
|
|
|
if args.create:
|
|
pwd = args.password or os.getenv("KEYSTORE_PASSWORD") or getpass("New password: ")
|
|
if not pwd:
|
|
print("Password required")
|
|
sys.exit(1)
|
|
generate_keypair(args.name, pwd, args.keystore_dir)
|
|
|
|
elif args.show:
|
|
pwd = args.password or os.getenv("KEYSTORE_PASSWORD") or getpass("Password: ")
|
|
if not pwd:
|
|
print("Password required")
|
|
sys.exit(1)
|
|
keystore_file = args.keystore_dir / f"{args.name}.json"
|
|
if not keystore_file.exists():
|
|
print(f"Keystore not found: {keystore_file}")
|
|
sys.exit(1)
|
|
show_keyinfo(keystore_file, pwd)
|
|
|
|
else:
|
|
parser.print_help()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|