stage-fed-a federation: ed25519 keypair + fingerprint
This commit is contained in:
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
"typer>=0.12",
|
"typer>=0.12",
|
||||||
"pynacl>=1.5",
|
"pynacl>=1.5",
|
||||||
|
"cryptography>=42.0",
|
||||||
"structlog>=24.1",
|
"structlog>=24.1",
|
||||||
"sqlalchemy>=2.0",
|
"sqlalchemy>=2.0",
|
||||||
"python-dotenv>=1.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