diff --git a/cmd_chat/agent/bridge.py b/cmd_chat/agent/bridge.py index aae8506..3891595 100644 --- a/cmd_chat/agent/bridge.py +++ b/cmd_chat/agent/bridge.py @@ -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 diff --git a/cmd_chat/sor/consent.py b/cmd_chat/sor/consent.py new file mode 100644 index 0000000..f66d42b --- /dev/null +++ b/cmd_chat/sor/consent.py @@ -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"} diff --git a/hh/Cargo.lock b/hh/Cargo.lock index 5a4f93a..1bc4a18 100644 --- a/hh/Cargo.lock +++ b/hh/Cargo.lock @@ -297,6 +297,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -340,10 +349,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -460,7 +484,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519", "serde", "sha2", @@ -515,6 +539,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -713,6 +743,7 @@ dependencies = [ "tungstenite", "url", "vt100", + "x25519-dalek", ] [[package]] @@ -1502,6 +1533,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -1830,7 +1867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1841,7 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2767,6 +2804,17 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 38259c0..d7a3a2a 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -50,6 +50,7 @@ serde_json = "1" # cli / errors clap = { version = "4", features = ["derive"] } anyhow = "1" +x25519-dalek = { version = "3.0.0", features = ["static_secrets"] } [dev-dependencies] # property-based fuzzing of the frame parsers (attacker-controlled JSON) diff --git a/hh/src/crypto.rs b/hh/src/crypto.rs index 6450b66..f409691 100644 --- a/hh/src/crypto.rs +++ b/hh/src/crypto.rs @@ -197,6 +197,163 @@ pub fn room_fernet(password: &[u8], room_salt: &[u8]) -> anyhow::Result anyhow::Result<[u8; 32]> { + let mut salt = Vec::with_capacity(64); + salt.extend_from_slice(epk); + salt.extend_from_slice(recipient_pub); + let hk = hkdf::Hkdf::::new(Some(&salt), shared); + let mut okm = [0u8; 32]; + hk.expand(SEAL_CTX, &mut okm) + .map_err(|_| anyhow::anyhow!("hkdf expand failed"))?; + Ok(okm) +} + +fn fernet_from_key(key: &[u8; 32]) -> anyhow::Result { + use base64::Engine; + let key_b64 = base64::engine::general_purpose::URL_SAFE.encode(key); + fernet::Fernet::new(&key_b64).ok_or_else(|| anyhow::anyhow!("invalid fernet key")) +} + +fn decode_32(b64: &str) -> anyhow::Result<[u8; 32]> { + use base64::Engine; + let raw = base64::engine::general_purpose::STANDARD.decode(b64)?; + <[u8; 32]>::try_from(raw.as_slice()).map_err(|_| anyhow::anyhow!("expected 32-byte key")) +} + +/// Generate a fresh X25519 keypair, returning `(secret_b64, public_b64)` in +/// STANDARD base64. The secret bytes are OS-random via `rand` 0.8 (avoids the +/// rand_core version skew with x25519-dalek's own RNG trait). +pub fn x25519_keypair() -> (String, String) { + use base64::Engine; + let mut sk = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut sk); + let secret = StaticSecret::from(sk); + let public = PublicKey::from(&secret); + let std = base64::engine::general_purpose::STANDARD; + (std.encode(secret.to_bytes()), std.encode(public.to_bytes())) +} + +/// Seal `plaintext` to `recipient_pub_b64` (STANDARD b64 X25519 pubkey). Only +/// the holder of the matching secret can `open` the result. +pub fn seal_to_pubkey(recipient_pub_b64: &str, plaintext: &[u8]) -> anyhow::Result { + use base64::Engine; + let recipient_pub = decode_32(recipient_pub_b64)?; + let mut esk = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut esk); + let eph = StaticSecret::from(esk); + let epk = PublicKey::from(&eph); + let shared = eph.diffie_hellman(&PublicKey::from(recipient_pub)); + let key = seal_key(shared.as_bytes(), epk.as_bytes(), &recipient_pub)?; + let token = fernet_from_key(&key)?.encrypt(plaintext); + let std = base64::engine::general_purpose::STANDARD; + Ok(format!("{}.{}", std.encode(epk.to_bytes()), token)) +} + +/// Open a `sealed` blob with `recipient_secret_b64` (STANDARD b64 X25519 +/// secret). Returns an error if the secret is wrong or the token is tampered. +pub fn open_sealed(recipient_secret_b64: &str, sealed: &str) -> anyhow::Result> { + let (epk_b64, token) = sealed + .split_once('.') + .ok_or_else(|| anyhow::anyhow!("malformed sealed blob"))?; + let epk = decode_32(epk_b64)?; + let sk = StaticSecret::from(decode_32(recipient_secret_b64)?); + let recipient_pub = PublicKey::from(&sk); + let shared = sk.diffie_hellman(&PublicKey::from(epk)); + let key = seal_key(shared.as_bytes(), &epk, recipient_pub.as_bytes())?; + fernet_from_key(&key)? + .decrypt(token) + .map_err(|_| anyhow::anyhow!("sealed-box open failed (wrong key or tampered)")) +} + +#[cfg(test)] +mod sealed_box { + use super::*; + + #[test] + fn roundtrip_only_recipient_opens() { + let (host_sk, host_pk) = x25519_keypair(); + let cred = b"hop-credential: ephemeral ssh key material"; + let sealed = seal_to_pubkey(&host_pk, cred).unwrap(); + + // The intended recipient opens it. + assert_eq!(open_sealed(&host_sk, &sealed).unwrap(), cred); + + // A third party with a different key cannot. + let (other_sk, _other_pk) = x25519_keypair(); + assert!(open_sealed(&other_sk, &sealed).is_err(), "third party must not open"); + } + + #[test] + fn tamper_is_rejected() { + let (host_sk, host_pk) = x25519_keypair(); + let sealed = seal_to_pubkey(&host_pk, b"secret").unwrap(); + // Flip a character in the token half. + let (epk, tok) = sealed.split_once('.').unwrap(); + let mut bad_tok: Vec = tok.chars().collect(); + let i = bad_tok.len() / 2; + bad_tok[i] = if bad_tok[i] == 'A' { 'B' } else { 'A' }; + let tampered = format!("{}.{}", epk, bad_tok.into_iter().collect::()); + assert!(open_sealed(&host_sk, &tampered).is_err(), "tamper must fail"); + } + + #[test] + fn each_seal_is_fresh() { + // Ephemeral epk per seal → two seals of the same plaintext differ. + let (_sk, pk) = x25519_keypair(); + let a = seal_to_pubkey(&pk, b"x").unwrap(); + let b = seal_to_pubkey(&pk, b"x").unwrap(); + assert_ne!(a, b, "fresh ephemeral key per seal"); + } + + #[test] + fn malformed_inputs_error_not_panic() { + assert!(open_sealed("not-b64", "also.not").is_err()); + assert!(seal_to_pubkey("not-a-key", b"x").is_err()); + let (sk, _pk) = x25519_keypair(); + assert!(open_sealed(&sk, "no-separator").is_err()); + } + + // Cross-language KDF parity: the hop-credential key derivation must be + // bit-identical to cmd_chat/sor/consent.py::_seal_key. Fixed (shared, epk, + // recipient_pub) triple -> fixed 32-byte key. If either side changes the + // salt layout or info label, this vector diverges. (Asserted in Python at + // tests/test_sor_consent.py::test_seal_key_matches_rust_vector.) + #[test] + fn seal_key_matches_python_vector() { + let shared: [u8; 32] = std::array::from_fn(|i| i as u8); + let epk: [u8; 32] = std::array::from_fn(|i| (i + 32) as u8); + let pub_: [u8; 32] = std::array::from_fn(|i| (i + 64) as u8); + let key = seal_key(&shared, &epk, &pub_).unwrap(); + assert_eq!( + hex::encode(key), + "7708606d3ae3f6c1fb103975302a77b65fc14d5363ee737a7eb8bdd0ac246e81", + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/hh/src/net.rs b/hh/src/net.rs index 944eed4..19f2640 100644 --- a/hh/src/net.rs +++ b/hh/src/net.rs @@ -194,6 +194,16 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded { Decoded::Skip }; } + // R5 in-band consent control frames are live-only and consumed + // out-of-band by the SOR circuit layer (R5 consent handlers / R4 + // forwarder), never surfaced as chat/app events. Classify to + // validate the frame, then skip it here. + if t.starts_with("{\"_sor\":") { + if live { + let _ = parse_sor(&t); + } + return Decoded::Skip; + } (t, false) } Err(_) => ("[unreadable — wrong room password?]".to_string(), true), @@ -242,6 +252,14 @@ fn parse_sbx(text: &str, sender: &str) -> Option { } } +/// Parse a decrypted `{"_sor":...}` R5 consent control frame. These frames are +/// live-only and consumed out-of-band by the SOR circuit layer (they never +/// become chat/app events), so `decode_msg` classifies then skips them. +/// Delegates to the consent module's never-panic parser. +fn parse_sor(text: &str) -> Option { + crate::sor::consent::parse_sor_frame(text) +} + /// Parse a decrypted `{"_ai":...}` frame from an AI agent. `"typing"` toggles the /// thinking spinner; `"stream"` carries the cumulative reply text for a live /// preview bubble (`done` clears it once the final message is posted). diff --git a/hh/src/sor/consent.rs b/hh/src/sor/consent.rs new file mode 100644 index 0000000..c91f2f1 --- /dev/null +++ b/hh/src/sor/consent.rs @@ -0,0 +1,382 @@ +//! R5 — In-band consent handshake + X25519 hop credentials. +//! +//! 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** +//! (`crypto::seal_to_pubkey`), so no third party — not even another room member +//! or the relay — can read it. Requests are signed with the Ed25519 persona and +//! verified with `persona::verify` before anything is accepted: an unsigned or +//! forged request is rejected and never yields a circuit entry. +//! +//! This module is the protocol + its wire parser. It performs no I/O, opens no +//! socket, and stands up no forwarder — those are R4, gated by the +//! isolated-engine assertion. Here we only decide *who* may be recruited and +//! mint the per-hop secret; the frame parser inherits the never-panic proptest +//! discipline of `net.rs`. + +use crate::crypto; +use crate::persona; +use serde_json::Value; + +/// Version tag bound into every signed consent message. Part of the wire +/// contract; never change in place. +const CONSENT_CTX: &str = "sor-consent-v1"; + +/// A parsed `{"_sor":...}` control frame. `Peer` is carried for R6 federation +/// and is only classified here (not acted upon). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SorFrame { + Request(ConsentRequest), + Accept(ConsentAccept), + Reject(ConsentReject), + /// R6 house-peer roster exchange — classified, handled later. + Peer, + /// A recognized `_sor` frame we don't act on yet. + Other, +} + +/// Host → node recruitment request. Signed by the host's Ed25519 persona over +/// [`ConsentRequest::canonical`]; carries the host's X25519 pubkey so the node +/// can seal a credential the host alone can open. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConsentRequest { + pub host_ed_pub: String, + pub host_x_pub: String, + pub circuit_id: String, + pub hop_index: u32, + pub nonce: String, + pub sig: String, +} + +impl ConsentRequest { + /// Canonical bytes that the host signs and the node verifies. Binding every + /// field means a signature can't be lifted onto a different circuit/hop or a + /// different host X25519 key. + pub fn canonical(&self) -> Vec { + format!( + "{CONSENT_CTX}\nrequest\n{}\n{}\n{}\n{}\n{}", + self.host_ed_pub, self.host_x_pub, self.circuit_id, self.hop_index, self.nonce + ) + .into_bytes() + } + + /// True iff the request carries a valid Ed25519 signature from the persona + /// it claims. This gates acceptance — an unsigned or forged request is + /// never honored. + pub fn signature_ok(&self) -> bool { + persona::verify(&self.host_ed_pub, &self.sig, &self.canonical()) + } +} + +/// Node → host acceptance carrying the sealed hop credential. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConsentAccept { + pub node_ed_pub: String, + pub circuit_id: String, + pub hop_index: u32, + /// Hop credential sealed to the host's X25519 pubkey (`crypto::seal_to_pubkey`). + pub sealed_cred: String, +} + +/// Node → host rejection. Leaves no circuit entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConsentReject { + pub node_ed_pub: String, + pub circuit_id: String, + pub hop_index: u32, + pub reason: String, +} + +/// A node's decision on a recruitment request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Decision { + Accept(ConsentAccept), + Reject(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 (`willing`), yields an acceptance +/// carrying a hop credential sealed to the host's X25519 key. +pub fn node_evaluate( + req: &ConsentRequest, + node_ed_pub: &str, + willing: bool, + hop_secret: &[u8], +) -> Decision { + if !req.signature_ok() { + return Decision::Reject(ConsentReject { + node_ed_pub: node_ed_pub.to_string(), + circuit_id: req.circuit_id.clone(), + hop_index: req.hop_index, + reason: "signature verification failed".to_string(), + }); + } + if !willing { + return Decision::Reject(ConsentReject { + node_ed_pub: node_ed_pub.to_string(), + circuit_id: req.circuit_id.clone(), + hop_index: req.hop_index, + reason: "declined".to_string(), + }); + } + match crypto::seal_to_pubkey(&req.host_x_pub, hop_secret) { + Ok(sealed_cred) => Decision::Accept(ConsentAccept { + node_ed_pub: node_ed_pub.to_string(), + circuit_id: req.circuit_id.clone(), + hop_index: req.hop_index, + sealed_cred, + }), + // If we cannot seal to the advertised host key, treat as a reject rather + // than silently dropping — the host learns no hop was recruited. + Err(_) => Decision::Reject(ConsentReject { + node_ed_pub: node_ed_pub.to_string(), + circuit_id: req.circuit_id.clone(), + hop_index: req.hop_index, + reason: "unsealable host key".to_string(), + }), + } +} + +/// One recruited hop in a circuit under construction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hop { + pub node_fp: String, + pub hop_index: u32, + pub cred: Vec, +} + +/// 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. +pub struct CircuitBuilder { + x_secret_b64: String, + pub circuit_id: String, + pub hops: Vec, +} + +impl CircuitBuilder { + pub fn new(circuit_id: &str, x_secret_b64: &str) -> Self { + CircuitBuilder { + x_secret_b64: x_secret_b64.to_string(), + circuit_id: circuit_id.to_string(), + hops: Vec::new(), + } + } + + /// Fold one decision into the circuit. Returns true iff a hop was recruited. + /// **Accept adds exactly one hop (after opening the sealed credential); + /// reject adds nothing.** A decision for a different circuit, or a sealed + /// credential the host cannot open, recruits no hop. + pub fn recruit(&mut self, decision: &Decision) -> bool { + match decision { + Decision::Reject(_) => false, + Decision::Accept(acc) => { + if acc.circuit_id != self.circuit_id { + return false; + } + match crypto::open_sealed(&self.x_secret_b64, &acc.sealed_cred) { + Ok(cred) => { + let node_fp = persona::fingerprint_of(&acc.node_ed_pub) + .unwrap_or_else(|| "unknown".to_string()); + self.hops.push(Hop { + node_fp, + hop_index: acc.hop_index, + cred, + }); + true + } + Err(_) => false, + } + } + } + } +} + +/// Parse a decrypted `{"_sor":...}` control frame. Classifies or rejects; never +/// panics on arbitrary input (proptest-covered). Semantic validation (signature, +/// sealing) is the caller's job via the protocol functions above. +pub fn parse_sor_frame(text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + let inner = v.get("_sor")?; + let s = |k: &str| inner.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string(); + let u = |k: &str| inner.get(k).and_then(|x| x.as_u64()).unwrap_or(0) as u32; + match inner.get("op").and_then(|x| x.as_str())? { + "request" => Some(SorFrame::Request(ConsentRequest { + host_ed_pub: s("host_ed"), + host_x_pub: s("host_x"), + circuit_id: s("cid"), + hop_index: u("hop"), + nonce: s("nonce"), + sig: s("sig"), + })), + "accept" => Some(SorFrame::Accept(ConsentAccept { + node_ed_pub: s("node_ed"), + circuit_id: s("cid"), + hop_index: u("hop"), + sealed_cred: s("sealed"), + })), + "reject" => Some(SorFrame::Reject(ConsentReject { + node_ed_pub: s("node_ed"), + circuit_id: s("cid"), + hop_index: u("hop"), + reason: s("reason"), + })), + "peer" => Some(SorFrame::Peer), + _ => Some(SorFrame::Other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use ed25519_dalek::{Signer, SigningKey}; + use proptest::prelude::*; + + const STD: base64::engine::general_purpose::GeneralPurpose = + base64::engine::general_purpose::STANDARD; + + // A throwaway Ed25519 signer standing in for a node/host persona. + fn signer(seed: u8) -> (SigningKey, String) { + let sk = SigningKey::from_bytes(&[seed; 32]); + let pub_b64 = STD.encode(sk.verifying_key().to_bytes()); + (sk, pub_b64) + } + + fn signed_request(host_ed_sk: &SigningKey, host_ed_pub: &str, host_x_pub: &str) -> ConsentRequest { + let mut req = ConsentRequest { + host_ed_pub: host_ed_pub.to_string(), + host_x_pub: host_x_pub.to_string(), + circuit_id: "c0".to_string(), + hop_index: 1, + nonce: "deadbeef".to_string(), + sig: String::new(), + }; + req.sig = STD.encode(host_ed_sk.sign(&req.canonical()).to_bytes()); + req + } + + // Full happy path: signed request -> node accepts with a sealed cred -> the + // host (and only the host) recruits the hop. + #[test] + fn accept_recruits_a_hop_only_for_the_host() { + let (host_ed_sk, host_ed_pub) = signer(1); + let (host_x_sk, host_x_pub) = crypto::x25519_keypair(); + let (_node_ed_sk, node_ed_pub) = signer(2); + + let req = signed_request(&host_ed_sk, &host_ed_pub, &host_x_pub); + assert!(req.signature_ok()); + + let decision = node_evaluate(&req, &node_ed_pub, true, b"ephemeral-hop-key"); + assert!(matches!(decision, Decision::Accept(_))); + + // Host with the right X25519 secret recruits the hop. + let mut cb = CircuitBuilder::new("c0", &host_x_sk); + assert!(cb.recruit(&decision), "accept must recruit a hop"); + assert_eq!(cb.hops.len(), 1); + assert_eq!(cb.hops[0].cred, b"ephemeral-hop-key"); + + // A different host (wrong X25519 secret) cannot open the cred → no hop. + let (other_x_sk, _other_x_pub) = crypto::x25519_keypair(); + let mut evil = CircuitBuilder::new("c0", &other_x_sk); + assert!(!evil.recruit(&decision), "third party must not recruit"); + assert!(evil.hops.is_empty()); + } + + // Signature gate: an unsigned/forged request is rejected and recruits nothing. + #[test] + fn forged_or_unsigned_request_is_rejected() { + let (_host_ed_sk, host_ed_pub) = signer(1); + let (_host_x_sk, host_x_pub) = crypto::x25519_keypair(); + let (_node_ed_sk, node_ed_pub) = signer(2); + + // Unsigned (empty sig). + let mut unsigned = ConsentRequest { + host_ed_pub: host_ed_pub.clone(), + host_x_pub: host_x_pub.clone(), + circuit_id: "c0".to_string(), + hop_index: 1, + nonce: "n".to_string(), + sig: String::new(), + }; + assert!(!unsigned.signature_ok()); + assert!(matches!( + node_evaluate(&unsigned, &node_ed_pub, true, b"k"), + Decision::Reject(_) + )); + + // Forged: signed by the WRONG key but claiming host_ed_pub. + let (wrong_sk, _wrong_pub) = signer(9); + unsigned.sig = STD.encode(wrong_sk.sign(&unsigned.canonical()).to_bytes()); + assert!(!unsigned.signature_ok(), "forged signature must not verify"); + assert!(matches!( + node_evaluate(&unsigned, &node_ed_pub, true, b"k"), + Decision::Reject(_) + )); + } + + // A tampered field (hop_index) breaks the signature (canonical binding). + #[test] + fn tampering_a_bound_field_breaks_signature() { + let (host_ed_sk, host_ed_pub) = signer(1); + let (_host_x_sk, host_x_pub) = crypto::x25519_keypair(); + let mut req = signed_request(&host_ed_sk, &host_ed_pub, &host_x_pub); + req.hop_index = 2; // was 1 when signed + assert!(!req.signature_ok()); + } + + // Reject leaves no circuit entry. + #[test] + fn reject_leaves_no_entry() { + let (host_ed_sk, host_ed_pub) = signer(1); + let (host_x_sk, host_x_pub) = crypto::x25519_keypair(); + let (_node_ed_sk, node_ed_pub) = signer(2); + let req = signed_request(&host_ed_sk, &host_ed_pub, &host_x_pub); + + // Node declines a validly-signed request. + let decision = node_evaluate(&req, &node_ed_pub, false, b"k"); + assert!(matches!(decision, Decision::Reject(_))); + let mut cb = CircuitBuilder::new("c0", &host_x_sk); + assert!(!cb.recruit(&decision)); + assert!(cb.hops.is_empty(), "reject must leave no circuit entry"); + } + + // Wire round-trip through the parser. + #[test] + fn parse_request_roundtrip() { + let frame = r#"{"_sor":{"op":"request","host_ed":"E","host_x":"X","cid":"c1","hop":3,"nonce":"nn","sig":"ss"}}"#; + match parse_sor_frame(frame) { + Some(SorFrame::Request(r)) => { + assert_eq!(r.host_ed_pub, "E"); + assert_eq!(r.host_x_pub, "X"); + assert_eq!(r.circuit_id, "c1"); + assert_eq!(r.hop_index, 3); + } + other => panic!("expected Request, got {other:?}"), + } + } + + proptest! { + // parse_sor_frame consumes attacker-controlled JSON from a zero-knowledge + // relay: it must classify or reject, never panic (net.rs discipline). + #[test] + fn parse_sor_never_panics(s in ".*") { + let _ = parse_sor_frame(&s); + } + + #[test] + fn parse_sor_structured_never_panics( + op in "[a-z]{0,10}", a in ".*", hop in any::() + ) { + let frame = serde_json::json!({ + "_sor": {"op": op, "host_ed": a, "host_x": a, "cid": a, + "hop": hop, "nonce": a, "sig": a, "node_ed": a, + "sealed": a, "reason": a} + }) + .to_string(); + let _ = parse_sor_frame(&frame); + } + } +} diff --git a/hh/src/sor/mod.rs b/hh/src/sor/mod.rs index 7c1adec..64620eb 100644 --- a/hh/src/sor/mod.rs +++ b/hh/src/sor/mod.rs @@ -16,6 +16,11 @@ //! stable across compiler/`rand` versions and is mirrored bit-for-bit in //! `cmd_chat/sor/config.py`. +// R5 — in-band consent handshake + per-hop X25519 sealed credentials. Lives in +// its own module; it consumes this module's determinism only indirectly (a hop +// secret is sealed per recipient) and adds no new source of stochasticity here. +pub mod consent; + use serde::Serialize; use sha2::{Digest, Sha256}; diff --git a/tests/test_sor_consent.py b/tests/test_sor_consent.py new file mode 100644 index 0000000..2306e1a --- /dev/null +++ b/tests/test_sor_consent.py @@ -0,0 +1,141 @@ +"""R5 — consent handshake + X25519 hop-credential acceptance checks (Python). + +Acceptance predicate (roadmap R5): a host recruits a hop only after an explicit +accept; a reject leaves no circuit entry; the hop credential decrypts only with +the host's X25519 key (a third party cannot open it); signature verification +gates acceptance (unsigned/forged request -> rejected). Mirrors the Rust checks +in hh/src/sor/consent.rs, and pins cross-language KDF parity with crypto.rs. +""" + +import base64 + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from cmd_chat.sor.consent import ( + CircuitBuilder, + ConsentAccept, + ConsentReject, + ConsentRequest, + _seal_key, + node_evaluate, + open_sealed, + parse_sor_frame, + persona_sign, + seal_to_pubkey, + x25519_keypair, +) + + +def _ed_signer(seed: int): + raw = bytes([seed]) * 32 + sk = Ed25519PrivateKey.from_private_bytes(raw) + pub_b64 = base64.standard_b64encode(sk.public_key().public_bytes_raw()).decode() + return raw, pub_b64 + + +def _signed_request(host_ed_raw, host_ed_pub, host_x_pub, circuit="c0", hop=1): + req = ConsentRequest(host_ed_pub, host_x_pub, circuit, hop, "deadbeef", "") + req.sig = persona_sign(host_ed_raw, req.canonical()) + return req + + +def test_accept_recruits_a_hop_only_for_the_host(): + host_ed_raw, host_ed_pub = _ed_signer(1) + host_x_sk, host_x_pub = x25519_keypair() + _, node_ed_pub = _ed_signer(2) + + req = _signed_request(host_ed_raw, host_ed_pub, host_x_pub) + assert req.signature_ok() + + decision = node_evaluate(req, node_ed_pub, True, b"ephemeral-hop-key") + assert isinstance(decision, ConsentAccept) + + cb = CircuitBuilder("c0", host_x_sk) + assert cb.recruit(decision), "accept must recruit a hop" + assert len(cb.hops) == 1 + assert cb.hops[0].cred == b"ephemeral-hop-key" + + # A different host (wrong X25519 secret) cannot open the cred -> no hop. + other_x_sk, _ = x25519_keypair() + evil = CircuitBuilder("c0", other_x_sk) + assert not evil.recruit(decision), "third party must not recruit" + assert evil.hops == [] + + +def test_forged_or_unsigned_request_is_rejected(): + _, host_ed_pub = _ed_signer(1) + _, host_x_pub = x25519_keypair() + _, node_ed_pub = _ed_signer(2) + + unsigned = ConsentRequest(host_ed_pub, host_x_pub, "c0", 1, "n", "") + assert not unsigned.signature_ok() + assert isinstance(node_evaluate(unsigned, node_ed_pub, True, b"k"), ConsentReject) + + # Forged: signed by the WRONG key but claiming host_ed_pub. + wrong_raw, _ = _ed_signer(9) + unsigned.sig = persona_sign(wrong_raw, unsigned.canonical()) + assert not unsigned.signature_ok(), "forged signature must not verify" + assert isinstance(node_evaluate(unsigned, node_ed_pub, True, b"k"), ConsentReject) + + +def test_tampering_a_bound_field_breaks_signature(): + host_ed_raw, host_ed_pub = _ed_signer(1) + _, host_x_pub = x25519_keypair() + req = _signed_request(host_ed_raw, host_ed_pub, host_x_pub) + req.hop_index = 2 # was 1 when signed + assert not req.signature_ok() + + +def test_reject_leaves_no_entry(): + host_ed_raw, host_ed_pub = _ed_signer(1) + host_x_sk, host_x_pub = x25519_keypair() + _, node_ed_pub = _ed_signer(2) + req = _signed_request(host_ed_raw, host_ed_pub, host_x_pub) + + decision = node_evaluate(req, node_ed_pub, False, b"k") + assert isinstance(decision, ConsentReject) + cb = CircuitBuilder("c0", host_x_sk) + assert not cb.recruit(decision) + assert cb.hops == [], "reject must leave no circuit entry" + + +def test_sealed_cred_only_host_opens(): + host_x_sk, host_x_pub = x25519_keypair() + sealed = seal_to_pubkey(host_x_pub, b"hop-credential") + assert open_sealed(host_x_sk, sealed) == b"hop-credential" + other_sk, _ = x25519_keypair() + with pytest.raises(ValueError): + open_sealed(other_sk, sealed) + + +def test_seal_key_matches_rust_vector(): + # Same fixed (shared, epk, recipient_pub) triple as + # hh/src/crypto.rs::sealed_box::seal_key_matches_python_vector. + shared = bytes(range(32)) + epk = bytes(range(32, 64)) + pub = bytes(range(64, 96)) + assert ( + _seal_key(shared, epk, pub).hex() + == "7708606d3ae3f6c1fb103975302a77b65fc14d5363ee737a7eb8bdd0ac246e81" + ) + + +def test_parse_request_roundtrip(): + frame = ( + '{"_sor":{"op":"request","host_ed":"E","host_x":"X","cid":"c1",' + '"hop":3,"nonce":"nn","sig":"ss"}}' + ) + parsed = parse_sor_frame(frame) + assert parsed is not None and parsed["kind"] == "request" + r = parsed["req"] + assert (r.host_ed_pub, r.host_x_pub, r.circuit_id, r.hop_index) == ("E", "X", "c1", 3) + + +@pytest.mark.parametrize( + "text", + ["", "not json", "{}", '{"_sor":123}', '{"_sor":{"op":42}}', + '{"_sor":{"op":"peer"}}', '{"_sor":{"op":"unknown"}}', "[]", "null"], +) +def test_parse_never_raises(text): + parse_sor_frame(text) # must not raise