stage-fed-a federation: ed25519 keypair + fingerprint
This commit is contained in:
@@ -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",
|
||||
|
||||
95
src/psyc/lines/federation.py
Normal file
95
src/psyc/lines/federation.py
Normal 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()
|
||||
Reference in New Issue
Block a user