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
+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"))
}
// ── 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)]
mod tests {
use super::*;