R5: consent-gated recruitment + per-hop X25519 sealed credentials

Add the in-band, signature-gated consent handshake that governs whether a
node may be recruited into a measured SOR circuit, plus a per-recipient
sealed box so a hop credential decrypts only with the host's X25519 key.

- crypto.rs: seal_to_pubkey/open_sealed (ephemeral X25519 -> HKDF-SHA256 ->
  Fernet AEAD), reusing the existing fernet crate (no new symmetric primitive).
- sor/consent.rs: signed ConsentRequest (Ed25519 persona), node_evaluate
  (reject unless the signature verifies), CircuitBuilder that recruits a hop
  only on an accept it can open; parse_sor_frame with never-panic proptests.
- net.rs: {"_sor":...} control frames are live-only and classified out-of-band
  by the SOR layer (parse_sor), never surfaced as chat/app events.
- cmd_chat/sor/consent.py + bridge _sor case: bit-compatible Python mirror;
  the agent only observes/classifies consent frames — it never auto-accepts
  and stands up no forwarder (forwarding is R4, isolated-engine-only).

Acceptance check (roadmap R5) green both languages: host recruits a hop only
after an explicit accept; a reject leaves no entry; the credential opens only
with the host key (third party cannot); unsigned/forged request -> rejected.
Cross-language KDF parity pinned by a shared known-answer vector.
Rust 75 passed; Python SOR suite 37 passed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-19 16:35:11 -07:00
parent a9308edc5d
commit 7de0f57fa6
9 changed files with 1057 additions and 5 deletions
+12
View File
@@ -394,6 +394,18 @@ class AgentBridge(Client):
self.sbx_name = ""
self.sbx_backend = None
return
# R5 in-band SOR consent frames: classify only. Recruitment into a SOR
# circuit is opt-in + signature-gated (cmd_chat/sor/consent.py); the
# actual forwarder is R4 and runs isolated-engine-only. The bridge never
# auto-accepts and stands up no forwarder here — it only recognizes the
# frame so it is not mistaken for an addressed message.
if "_sor" in frame:
from cmd_chat.sor.consent import parse_sor_frame
parsed = parse_sor_frame(text)
if parsed is not None:
self.info(f"observed SOR consent frame ({parsed['kind']})")
return
if frame.get("_perm") != "acl":
return
was = self.granted
+288
View File
@@ -0,0 +1,288 @@
"""R5 — In-band consent handshake + X25519 hop credentials (Python mirror).
This is the bit-for-bit Python counterpart of ``hh/src/sor/consent.rs`` and the
sealed-box in ``hh/src/crypto.rs``. Recruitment into a SOR circuit is opt-in and
signed: a host broadcasts a signed ``{"_sor":{"op":"request",...}}`` control
frame (invisible to the zero-knowledge server); each node renders accept/reject;
on accept the node returns an ephemeral hop credential **sealed to the host's
X25519 key only**, so no third party — not even another room member or the relay
— can read it. Requests are signed with the Ed25519 persona and verified before
anything is accepted: an unsigned or forged request is rejected and never yields
a circuit entry.
Wire/crypto contract (mirrors the Rust; never change in place):
epk, esk = ephemeral X25519 keypair (fresh per seal -> forward secrecy)
shared = X25519(esk, recipient_pub)
key = HKDF-SHA256(ikm=shared, salt=epk||recipient_pub, info=b"sor-hop-cred-v1")[:32]
token = Fernet(urlsafe_b64(key)).encrypt(plaintext)
sealed = base64(epk) || "." || fernet_token
This module performs no I/O, opens no socket, and stands up no forwarder — those
are R4, gated by the isolated-engine assertion. It only decides *who* may be
recruited and mints the per-hop secret. The frame parser never raises on
arbitrary input (mirrors the never-panic discipline of ``net.rs``/``consent.rs``).
"""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import List, Optional
from cryptography.exceptions import InvalidSignature
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
X25519PublicKey,
)
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.hashes import SHA256
# Version tag bound into every signed consent message. Part of the wire
# contract; mirrored in consent.rs; never change in place.
CONSENT_CTX = "sor-consent-v1"
# Domain-separation label for the hop-credential KDF. Mirrors crypto.rs SEAL_CTX.
SEAL_CTX = b"sor-hop-cred-v1"
# --------------------------------------------------------------------------- #
# X25519 sealed box — bit-compatible with hh/src/crypto.rs.
# --------------------------------------------------------------------------- #
def _b64e(raw: bytes) -> str:
return base64.standard_b64encode(raw).decode("ascii")
def _b64d_32(b64: str) -> bytes:
raw = base64.standard_b64decode(b64)
if len(raw) != 32:
raise ValueError("expected 32-byte key")
return raw
def _seal_key(shared: bytes, epk: bytes, recipient_pub: bytes) -> bytes:
"""HKDF-SHA256(ikm=shared, salt=epk||recipient_pub, info=SEAL_CTX)[:32]."""
return HKDF(
algorithm=SHA256(),
length=32,
salt=epk + recipient_pub,
info=SEAL_CTX,
).derive(shared)
def _fernet_from_key(key: bytes) -> Fernet:
# Rust uses URL_SAFE (padded) base64 of the 32-byte key; match exactly.
return Fernet(base64.urlsafe_b64encode(key))
def x25519_keypair() -> tuple[str, str]:
"""Fresh X25519 keypair as ``(secret_b64, public_b64)`` in STANDARD base64."""
sk = X25519PrivateKey.generate()
sk_raw = sk.private_bytes_raw()
pk_raw = sk.public_key().public_bytes_raw()
return _b64e(sk_raw), _b64e(pk_raw)
def seal_to_pubkey(recipient_pub_b64: str, plaintext: bytes) -> str:
"""Seal ``plaintext`` to ``recipient_pub_b64``. Only the holder of the
matching secret can :func:`open_sealed` the result."""
recipient_pub = _b64d_32(recipient_pub_b64)
eph = X25519PrivateKey.generate()
epk = eph.public_key().public_bytes_raw()
shared = eph.exchange(X25519PublicKey.from_public_bytes(recipient_pub))
key = _seal_key(shared, epk, recipient_pub)
token = _fernet_from_key(key).encrypt(plaintext).decode("ascii")
return f"{_b64e(epk)}.{token}"
def open_sealed(recipient_secret_b64: str, sealed: str) -> bytes:
"""Open a ``sealed`` blob with ``recipient_secret_b64``. Raises on a wrong
secret or a tampered token."""
epk_b64, _, token = sealed.partition(".")
if not token:
raise ValueError("malformed sealed blob")
epk = _b64d_32(epk_b64)
sk = X25519PrivateKey.from_private_bytes(_b64d_32(recipient_secret_b64))
recipient_pub = sk.public_key().public_bytes_raw()
shared = sk.exchange(X25519PublicKey.from_public_bytes(epk))
key = _seal_key(shared, epk, recipient_pub)
try:
return _fernet_from_key(key).decrypt(token.encode("ascii"))
except InvalidToken as exc: # wrong key or tampered
raise ValueError("sealed-box open failed (wrong key or tampered)") from exc
# --------------------------------------------------------------------------- #
# Ed25519 persona sign/verify — mirrors hh/src/persona.rs::{sign,verify}.
# --------------------------------------------------------------------------- #
def persona_verify(pub_b64: str, sig_b64: str, msg: bytes) -> bool:
"""True iff ``sig_b64`` is a valid Ed25519 signature over ``msg`` by the
persona ``pub_b64``. Never raises — any decode/verify failure is ``False``."""
try:
pk = Ed25519PublicKey.from_public_bytes(base64.standard_b64decode(pub_b64))
pk.verify(base64.standard_b64decode(sig_b64), msg)
return True
except (InvalidSignature, ValueError, Exception): # noqa: BLE001
return False
def persona_sign(secret_raw: bytes, msg: bytes) -> str:
"""Sign ``msg`` with a raw 32-byte Ed25519 seed; return STANDARD b64 sig."""
sk = Ed25519PrivateKey.from_private_bytes(secret_raw)
return _b64e(sk.sign(msg))
# --------------------------------------------------------------------------- #
# Consent protocol — mirrors the structs/decisions in consent.rs.
# --------------------------------------------------------------------------- #
@dataclass
class ConsentRequest:
host_ed_pub: str
host_x_pub: str
circuit_id: str
hop_index: int
nonce: str
sig: str
def canonical(self) -> bytes:
"""Canonical signed bytes — identical to consent.rs::canonical."""
return (
f"{CONSENT_CTX}\nrequest\n{self.host_ed_pub}\n{self.host_x_pub}\n"
f"{self.circuit_id}\n{self.hop_index}\n{self.nonce}"
).encode("utf-8")
def signature_ok(self) -> bool:
return persona_verify(self.host_ed_pub, self.sig, self.canonical())
@dataclass
class ConsentAccept:
node_ed_pub: str
circuit_id: str
hop_index: int
sealed_cred: str
@dataclass
class ConsentReject:
node_ed_pub: str
circuit_id: str
hop_index: int
reason: str
def node_evaluate(
req: ConsentRequest,
node_ed_pub: str,
willing: bool,
hop_secret: bytes,
) -> ConsentAccept | ConsentReject:
"""Node side: evaluate a recruitment request. Signature-gated — a request
whose signature does not verify is rejected outright (no credential minted).
A verified request, if the node opts in, yields an acceptance carrying a hop
credential sealed to the host's X25519 key."""
if not req.signature_ok():
return ConsentReject(node_ed_pub, req.circuit_id, req.hop_index,
"signature verification failed")
if not willing:
return ConsentReject(node_ed_pub, req.circuit_id, req.hop_index, "declined")
try:
sealed = seal_to_pubkey(req.host_x_pub, hop_secret)
except (ValueError, Exception): # noqa: BLE001 — unsealable advertised key
return ConsentReject(node_ed_pub, req.circuit_id, req.hop_index,
"unsealable host key")
return ConsentAccept(node_ed_pub, req.circuit_id, req.hop_index, sealed)
@dataclass
class Hop:
node_fp: str
hop_index: int
cred: bytes
def _fingerprint_of(pub_b64: str) -> str:
"""sha256(raw pubkey)[:4] hex — mirrors persona.rs::fingerprint_of."""
import hashlib
try:
raw = base64.standard_b64decode(pub_b64)
return hashlib.sha256(raw).digest()[:4].hex()
except Exception: # noqa: BLE001
return "unknown"
class CircuitBuilder:
"""Host side: assembles a circuit from consent decisions. The host holds the
X25519 secret matching the pubkey it advertised, so it — and only it — can
open the sealed credentials."""
def __init__(self, circuit_id: str, x_secret_b64: str) -> None:
self.circuit_id = circuit_id
self._x_secret_b64 = x_secret_b64
self.hops: List[Hop] = []
def recruit(self, decision: ConsentAccept | ConsentReject) -> bool:
"""Fold one decision into the circuit. Accept adds exactly one hop (after
opening the sealed credential); reject adds nothing. A decision for a
different circuit, or a credential the host cannot open, recruits no hop.
Returns True iff a hop was recruited."""
if not isinstance(decision, ConsentAccept):
return False
if decision.circuit_id != self.circuit_id:
return False
try:
cred = open_sealed(self._x_secret_b64, decision.sealed_cred)
except (ValueError, Exception): # noqa: BLE001
return False
self.hops.append(Hop(_fingerprint_of(decision.node_ed_pub),
decision.hop_index, cred))
return True
# --------------------------------------------------------------------------- #
# Wire parser — mirrors consent.rs::parse_sor_frame. Never raises.
# --------------------------------------------------------------------------- #
def parse_sor_frame(text: str) -> Optional[dict]:
"""Parse a decrypted ``{"_sor":...}`` control frame into a small tagged dict
``{"kind": ..., ...}`` (or ``None`` if it is not a recognized SOR frame).
Classifies or rejects; never raises on arbitrary input."""
try:
v = json.loads(text)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(v, dict):
return None
inner = v.get("_sor")
if not isinstance(inner, dict):
return None
op = inner.get("op")
if not isinstance(op, str):
return None
def s(k: str) -> str:
val = inner.get(k)
return val if isinstance(val, str) else ""
def u(k: str) -> int:
val = inner.get(k)
return val if isinstance(val, int) and not isinstance(val, bool) else 0
if op == "request":
return {"kind": "request", "req": ConsentRequest(
s("host_ed"), s("host_x"), s("cid"), u("hop"), s("nonce"), s("sig"))}
if op == "accept":
return {"kind": "accept", "accept": ConsentAccept(
s("node_ed"), s("cid"), u("hop"), s("sealed"))}
if op == "reject":
return {"kind": "reject", "reject": ConsentReject(
s("node_ed"), s("cid"), u("hop"), s("reason"))}
if op == "peer":
return {"kind": "peer"}
return {"kind": "other"}