stage-fed-a federation: ed25519 keypair + fingerprint

This commit is contained in:
m17hr1l
2026-06-06 16:08:03 +02:00
parent 6356c5535b
commit 4c35aad2bb
2 changed files with 96 additions and 0 deletions

View File

@@ -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",

View File

@@ -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()