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_name = ""
self.sbx_backend = None self.sbx_backend = None
return 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": if frame.get("_perm") != "acl":
return return
was = self.granted 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"}
Generated
+53 -5
View File
@@ -297,6 +297,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.28.1" version = "0.28.1"
@@ -340,10 +349,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest", "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", "rustc_version",
"subtle", "subtle",
"zeroize", "zeroize",
@@ -460,7 +484,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [ dependencies = [
"curve25519-dalek", "curve25519-dalek 4.1.3",
"ed25519", "ed25519",
"serde", "serde",
"sha2", "sha2",
@@ -515,6 +539,12 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "fiat-crypto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@@ -713,6 +743,7 @@ dependencies = [
"tungstenite", "tungstenite",
"url", "url",
"vt100", "vt100",
"x25519-dalek",
] ]
[[package]] [[package]]
@@ -1502,6 +1533,12 @@ dependencies = [
"getrandom 0.3.4", "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]] [[package]]
name = "rand_xorshift" name = "rand_xorshift"
version = "0.4.0" version = "0.4.0"
@@ -1830,7 +1867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -1841,7 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -2767,6 +2804,17 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 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]] [[package]]
name = "xattr" name = "xattr"
version = "1.6.1" version = "1.6.1"
+1
View File
@@ -50,6 +50,7 @@ serde_json = "1"
# cli / errors # cli / errors
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
anyhow = "1" anyhow = "1"
x25519-dalek = { version = "3.0.0", features = ["static_secrets"] }
[dev-dependencies] [dev-dependencies]
# property-based fuzzing of the frame parsers (attacker-controlled JSON) # property-based fuzzing of the frame parsers (attacker-controlled JSON)
+157
View File
@@ -197,6 +197,163 @@ pub fn room_fernet(password: &[u8], room_salt: &[u8]) -> anyhow::Result<fernet::
fernet::Fernet::new(&key_b64).ok_or_else(|| anyhow::anyhow!("invalid fernet key")) fernet::Fernet::new(&key_b64).ok_or_else(|| anyhow::anyhow!("invalid fernet key"))
} }
// ── Per-recipient sealed box (R5 hop credentials) ───────────────────────────
//
// Today's room crypto is a single shared-symmetric Fernet key: everyone in the
// room can decrypt everything. A hop credential must instead be readable by
// exactly one recipient (the host that recruited the hop), so R5 adds a NEW
// asymmetric path: seal a payload to a recipient's X25519 public key such that
// ONLY the matching X25519 secret can open it. A third party — including other
// room members — cannot.
//
// Construction (our own, versioned — not libsodium crypto_box_seal interop):
// 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=CTX)[..32]
// token = Fernet(urlsafe_b64(key)).encrypt(plaintext) (AES-128-CBC+HMAC)
// sealed = base64(epk) || "." || fernet_token
// Authenticity/integrity come from Fernet's HMAC over the derived key; binding
// epk+recipient_pub into the HKDF salt ties the token to this exact recipient.
use x25519_dalek::{PublicKey, StaticSecret};
/// Domain-separation label for the hop-credential KDF. Part of the wire
/// contract — mirrored in cmd_chat/sor/consent.py; never change in place.
const SEAL_CTX: &[u8] = b"sor-hop-cred-v1";
fn seal_key(shared: &[u8; 32], epk: &[u8; 32], recipient_pub: &[u8; 32]) -> 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::<Sha256>::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<fernet::Fernet> {
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<String> {
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<Vec<u8>> {
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<char> = 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::<String>());
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+18
View File
@@ -194,6 +194,16 @@ fn decode_msg(room: &fernet::Fernet, m: &Value, live: bool) -> Decoded {
Decoded::Skip 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) (t, false)
} }
Err(_) => ("[unreadable — wrong room password?]".to_string(), true), Err(_) => ("[unreadable — wrong room password?]".to_string(), true),
@@ -242,6 +252,14 @@ fn parse_sbx(text: &str, sender: &str) -> Option<Net> {
} }
} }
/// 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::SorFrame> {
crate::sor::consent::parse_sor_frame(text)
}
/// Parse a decrypted `{"_ai":...}` frame from an AI agent. `"typing"` toggles the /// Parse a decrypted `{"_ai":...}` frame from an AI agent. `"typing"` toggles the
/// thinking spinner; `"stream"` carries the cumulative reply text for a live /// thinking spinner; `"stream"` carries the cumulative reply text for a live
/// preview bubble (`done` clears it once the final message is posted). /// preview bubble (`done` clears it once the final message is posted).
+382
View File
@@ -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<u8> {
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<u8>,
}
/// 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<Hop>,
}
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<SorFrame> {
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::<i64>()
) {
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);
}
}
}
+5
View File
@@ -16,6 +16,11 @@
//! stable across compiler/`rand` versions and is mirrored bit-for-bit in //! stable across compiler/`rand` versions and is mirrored bit-for-bit in
//! `cmd_chat/sor/config.py`. //! `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 serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
+141
View File
@@ -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