diff --git a/pyproject.toml b/pyproject.toml index 74b3cd4..6a4f932 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "httpx>=0.27", "typer>=0.12", "pynacl>=1.5", + "cryptography>=42.0", "structlog>=24.1", "sqlalchemy>=2.0", "python-dotenv>=1.0", diff --git a/src/psyc/lines/federation.py b/src/psyc/lines/federation.py new file mode 100644 index 0000000..28c6d9f --- /dev/null +++ b/src/psyc/lines/federation.py @@ -0,0 +1,95 @@ +"""Federation — node identity, signed feeds, peer registry. + +Identity layer for internet-wide federation of psyc nodes. Each node owns +an Ed25519 keypair persisted under DATA_DIR/federation/. The public key +fingerprint (first 16 bytes of SHA256(raw_pubkey) hex-encoded) goes into a +DNS TXT record so peers can discover and authenticate the node, and the +private key signs the outbound feed at /federation/feed. + +This module is the *identity* primitives only — discovery walkers, +vouching/quorum, transparency log and auto-pull live in later stages. +""" + +from __future__ import annotations + +import hashlib +import os +from typing import Tuple + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from psyc import DATA_DIR, log + + +_log = log.get(__name__) + +FED_DIR = DATA_DIR / "federation" +PRIVATE_KEY_PATH = FED_DIR / "node.key" +PUBLIC_KEY_PATH = FED_DIR / "node.pub" + + +# ---------- keypair persistence ----------------------------------------- + +def _ensure_dir() -> None: + FED_DIR.mkdir(parents=True, exist_ok=True) + + +def node_keypair() -> Tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]: + """Return the node's Ed25519 keypair, generating + persisting it on first call. + + Private key lands at data/federation/node.key (PEM, chmod 0600); public + at data/federation/node.pub (PEM). Idempotent — subsequent calls load + the existing files instead of generating new ones. + """ + _ensure_dir() + if PRIVATE_KEY_PATH.exists() and PUBLIC_KEY_PATH.exists(): + priv_pem = PRIVATE_KEY_PATH.read_bytes() + priv = serialization.load_pem_private_key(priv_pem, password=None) + if not isinstance(priv, ed25519.Ed25519PrivateKey): + raise RuntimeError(f"federation key at {PRIVATE_KEY_PATH} is not Ed25519") + return priv, priv.public_key() + + priv = ed25519.Ed25519PrivateKey.generate() + priv_pem = priv.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + pub = priv.public_key() + pub_pem = pub.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + PRIVATE_KEY_PATH.write_bytes(priv_pem) + os.chmod(PRIVATE_KEY_PATH, 0o600) + PUBLIC_KEY_PATH.write_bytes(pub_pem) + _log.info("federation.keypair.generated", path=str(PRIVATE_KEY_PATH)) + return priv, pub + + +def public_key_pem() -> str: + """PEM-encoded public key as text — what peers store + verify against.""" + _, pub = node_keypair() + return pub.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + + +def _raw_pubkey_bytes(pub: ed25519.Ed25519PublicKey) -> bytes: + return pub.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + +def node_fingerprint() -> str: + """Short stable id for the node — first 16 bytes of SHA256(raw_pubkey), hex. + + Lives in DNS TXT records; 32 hex chars is short enough to fit but long + enough to be collision-safe for any plausible peer population. + """ + _, pub = node_keypair() + digest = hashlib.sha256(_raw_pubkey_bytes(pub)).digest() + return digest[:16].hex()