"""Ed25519 key management and signing. Citizens authenticate ballots with Ed25519 signatures. Keys are stored and exchanged as raw 32-byte values encoded as lowercase hex (64 hex chars); signatures are raw 64-byte values as hex (128 hex chars). Raw encoding keeps the registry, ballots, and ledger entries trivially machine-parseable and diff-able — no PEM armor in governance records. """ from __future__ import annotations import os import stat from pathlib import Path from typing import Tuple, Union from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, ) from .errors import KeyManagementError, SignatureError PRIVATE_KEY_HEX_LEN = 64 # 32 bytes PUBLIC_KEY_HEX_LEN = 64 # 32 bytes SIGNATURE_HEX_LEN = 128 # 64 bytes def _decode_hex(value: str, expected_len: int, what: str) -> bytes: if not isinstance(value, str): raise KeyManagementError(f"{what} must be a hex string") value = value.strip().lower() if len(value) != expected_len: raise KeyManagementError( f"{what} must be {expected_len} hex characters, got {len(value)}" ) try: return bytes.fromhex(value) except ValueError as exc: raise KeyManagementError(f"{what} is not valid hex: {exc}") from exc def generate_keypair() -> Tuple[str, str]: """Generate a new Ed25519 keypair. Returns ``(private_key_hex, public_key_hex)``. """ private = Ed25519PrivateKey.generate() private_bytes = private.private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption(), ) public_bytes = private.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) return private_bytes.hex(), public_bytes.hex() def public_key_from_private(private_key_hex: str) -> str: """Derive the hex public key from a hex private key.""" raw = _decode_hex(private_key_hex, PRIVATE_KEY_HEX_LEN, "private key") private = Ed25519PrivateKey.from_private_bytes(raw) return private.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ).hex() def sign(private_key_hex: str, message: bytes) -> str: """Sign *message* with the given private key. Returns the hex signature.""" if not isinstance(message, bytes): raise SignatureError("message to sign must be bytes") raw = _decode_hex(private_key_hex, PRIVATE_KEY_HEX_LEN, "private key") private = Ed25519PrivateKey.from_private_bytes(raw) return private.sign(message).hex() def verify(public_key_hex: str, message: bytes, signature_hex: str) -> bool: """Verify a signature. Returns True/False; never raises on a bad signature. Malformed keys raise :class:`KeyManagementError`; a malformed signature simply fails verification. """ if not isinstance(message, bytes): raise SignatureError("message to verify must be bytes") raw_pub = _decode_hex(public_key_hex, PUBLIC_KEY_HEX_LEN, "public key") try: raw_sig = bytes.fromhex(signature_hex.strip().lower()) except (ValueError, AttributeError): return False if len(raw_sig) != SIGNATURE_HEX_LEN // 2: return False public = Ed25519PublicKey.from_public_bytes(raw_pub) try: public.verify(raw_sig, message) return True except InvalidSignature: return False def write_private_key(path: Union[str, Path], private_key_hex: str) -> None: """Write a private key to *path* with owner-only permissions.""" _decode_hex(private_key_hex, PRIVATE_KEY_HEX_LEN, "private key") # validate path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(private_key_hex.strip().lower() + "\n", encoding="utf-8") try: os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600 except OSError: # Best effort on filesystems without POSIX permissions (e.g. Windows). pass def read_private_key(path: Union[str, Path]) -> str: """Read and validate a hex private key from *path*.""" path = Path(path) try: text = path.read_text(encoding="utf-8").strip() except OSError as exc: raise KeyManagementError(f"cannot read private key {path}: {exc}") from exc _decode_hex(text, PRIVATE_KEY_HEX_LEN, "private key") return text.lower()