"""Ed25519 identities and signatures for FablePool (FP/1). Key identifiers are self-describing strings: ``ed25519:`` Signatures are unpadded base64url of the raw 64-byte Ed25519 signature. The same identity format is used for users, nodes, and delegates; trust distinctions are carried by operations (grants, revocations), never by key format. """ from __future__ import annotations import base64 import os from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import ( Ed25519PrivateKey, Ed25519PublicKey, ) KEY_PREFIX = "ed25519:" SEED_BYTES = 32 class SignatureError(ValueError): """Raised for malformed keys, malformed signatures, or failed verification.""" def b64u_encode(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") def b64u_decode(text: str) -> bytes: if not isinstance(text, str): raise SignatureError("expected base64url string") padding = "=" * (-len(text) % 4) try: return base64.urlsafe_b64decode(text + padding) except Exception as exc: # binascii.Error and friends raise SignatureError(f"invalid base64url payload: {exc}") from exc class Identity: """A signing identity (private key holder).""" def __init__(self, private_key: Ed25519PrivateKey): self._private = private_key public_raw = private_key.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) self._key_id = KEY_PREFIX + b64u_encode(public_raw) @classmethod def generate(cls) -> "Identity": return cls(Ed25519PrivateKey.generate()) @classmethod def from_seed(cls, seed: bytes) -> "Identity": """Deterministic identity from a 32-byte seed (used for demo fixtures).""" if len(seed) != SEED_BYTES: raise SignatureError(f"seed must be {SEED_BYTES} bytes, got {len(seed)}") return cls(Ed25519PrivateKey.from_private_bytes(seed)) @classmethod def from_seed_phrase(cls, phrase: str) -> "Identity": """Deterministic demo identity from an arbitrary string. NOT a key-derivation function for real secrets; used so demo scripts and conformance tests are reproducible across machines. """ import hashlib return cls.from_seed(hashlib.sha256(phrase.encode("utf-8")).digest()) @property def key_id(self) -> str: return self._key_id def seed(self) -> bytes: """Export the raw 32-byte private seed (for local persistence only).""" return self._private.private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption(), ) def sign(self, data: bytes) -> str: return b64u_encode(self._private.sign(data)) @staticmethod def random_seed() -> bytes: return os.urandom(SEED_BYTES) def public_key_from_id(key_id: str) -> Ed25519PublicKey: if not isinstance(key_id, str) or not key_id.startswith(KEY_PREFIX): raise SignatureError(f"unsupported key identifier: {key_id!r}") raw = b64u_decode(key_id[len(KEY_PREFIX):]) if len(raw) != 32: raise SignatureError("ed25519 public key must be 32 raw bytes") try: return Ed25519PublicKey.from_public_bytes(raw) except Exception as exc: raise SignatureError(f"invalid ed25519 public key: {exc}") from exc def is_key_id(text: object) -> bool: if not isinstance(text, str) or not text.startswith(KEY_PREFIX): return False try: return len(b64u_decode(text[len(KEY_PREFIX):])) == 32 except SignatureError: return False def verify_signature(key_id: str, signature: str, data: bytes) -> None: """Verify ``signature`` over ``data`` for ``key_id``; raise SignatureError.""" public = public_key_from_id(key_id) raw_sig = b64u_decode(signature) if len(raw_sig) != 64: raise SignatureError("ed25519 signature must be 64 raw bytes") try: public.verify(raw_sig, data) except InvalidSignature as exc: raise SignatureError("signature verification failed") from exc