SSE streaming with long-poll fallback; encrypted unicast profile; registry enc keys
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::{
|
||||
ask, client, collection, config_for, poll_messages, publish, query_envelope, register,
|
||||
spawn_relay,
|
||||
};
|
||||
use frxd::config::Config;
|
||||
use frxd::crypto::{self, Keypair, now_ts};
|
||||
use frxd::index::LocalIndex;
|
||||
use frxd::message::{EXPOSURE_FULL, TYPE_RESPONSE};
|
||||
use frxd::node::Node;
|
||||
use frxd::registry::{self, KeyEntry, RegistryDoc, RegistryMember};
|
||||
use serde_json::Value;
|
||||
|
||||
fn member_with_enc(id: &str, ed: &Keypair, enc_public: &str) -> RegistryMember {
|
||||
RegistryMember {
|
||||
id: id.to_string(),
|
||||
class: "source".to_string(),
|
||||
keys: vec![KeyEntry {
|
||||
key: ed.public_hex(),
|
||||
not_before: 0,
|
||||
not_after: None,
|
||||
}],
|
||||
enc_key: Some(enc_public.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_registry(dir: &Path, ma: &Keypair, members: Vec<RegistryMember>) -> PathBuf {
|
||||
let path = dir.join("registry.json");
|
||||
registry::save_registry(
|
||||
&path,
|
||||
®istry::sign_registry(
|
||||
RegistryDoc {
|
||||
version: 1,
|
||||
issued_at: now_ts(),
|
||||
ma_key: String::new(),
|
||||
members,
|
||||
relays: Vec::new(),
|
||||
},
|
||||
ma,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn node_config(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
id: &str,
|
||||
relay_url: &str,
|
||||
registry_path: &Path,
|
||||
ma_key: &str,
|
||||
enc_secret: &str,
|
||||
) -> Config {
|
||||
let mut config = config_for(dir, name, relay_url);
|
||||
config.node.id = Some(id.to_string());
|
||||
config.node.registry = Some(registry_path.display().to_string());
|
||||
config.node.ma_key = Some(ma_key.to_string());
|
||||
config.node.dev_bootstrap = false;
|
||||
config.save_enc_key(enc_secret).unwrap();
|
||||
config
|
||||
}
|
||||
|
||||
async fn response_envelopes(
|
||||
http: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
asker: &Keypair,
|
||||
text: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
register(http, relay_url, asker).await;
|
||||
let envelope = query_envelope(asker, "alice.frx.example", text, 5);
|
||||
assert!(
|
||||
publish(http, relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let start = Instant::now();
|
||||
let deadline = Duration::from_millis(timeout_ms);
|
||||
let mut responses = Vec::new();
|
||||
while start.elapsed() < deadline {
|
||||
let messages = poll_messages(http, relay_url, asker, 200).await;
|
||||
responses.extend(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| m.get("type").and_then(Value::as_str) == Some(TYPE_RESPONSE))
|
||||
.cloned(),
|
||||
);
|
||||
if !responses.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
responses
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn relay_sees_ciphertext_and_recipient_decrypts() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
|
||||
let alice = Keypair::generate();
|
||||
let (alice_enc_secret, alice_enc_public) = crypto::generate_enc_keypair();
|
||||
let (bob_enc_secret, bob_enc_public) = {
|
||||
let pair = crypto::generate_enc_keypair();
|
||||
(pair.0, pair.1)
|
||||
};
|
||||
|
||||
let bob_docs = root.path().join("bob-docs");
|
||||
fs::create_dir_all(&bob_docs).unwrap();
|
||||
fs::write(bob_docs.join("doc.txt"), "confidential rust document").unwrap();
|
||||
let bob_config = node_config(
|
||||
&root.path().join("bob"),
|
||||
"bob",
|
||||
"bob.frx.example",
|
||||
&relay_url,
|
||||
&root.path().join("registry.json"),
|
||||
&ma.public_hex(),
|
||||
&bob_enc_secret,
|
||||
);
|
||||
let registry_path = save_registry(
|
||||
root.path(),
|
||||
&ma,
|
||||
vec![
|
||||
member_with_enc("alice.frx.example", &alice, &alice_enc_public),
|
||||
member_with_enc(
|
||||
"bob.frx.example",
|
||||
&bob_config.load_key().unwrap(),
|
||||
&bob_enc_public,
|
||||
),
|
||||
],
|
||||
);
|
||||
{
|
||||
let index = LocalIndex::open(&bob_config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &bob_docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob = Node::start(bob_config).await.unwrap();
|
||||
let _ = ®istry_path;
|
||||
|
||||
let http = client();
|
||||
let responses = response_envelopes(&http, &relay_url, &alice, "rust", 900).await;
|
||||
assert_eq!(responses.len(), 1, "expected one encrypted response");
|
||||
let response = &responses[0];
|
||||
let raw = serde_json::to_string(response).unwrap();
|
||||
assert!(
|
||||
!raw.contains("\"results\""),
|
||||
"relay-visible payload leaked plaintext: {raw}"
|
||||
);
|
||||
let enc = response.pointer("/body/enc").unwrap();
|
||||
assert_eq!(
|
||||
enc.get("alg").and_then(Value::as_str),
|
||||
Some(crypto::ENC_ALG)
|
||||
);
|
||||
|
||||
let context = crypto::unicast_context("bob.frx.example", &alice_enc_public);
|
||||
let plaintext = crypto::decrypt_unicast(&alice_enc_secret, &context, enc).unwrap();
|
||||
let body: Value = serde_json::from_slice(&plaintext).unwrap();
|
||||
assert_eq!(
|
||||
body.get("results").and_then(Value::as_array).map(Vec::len),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn node_decrypts_encrypted_responses_end_to_end() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
|
||||
let (alice_enc_secret, alice_enc_public) = crypto::generate_enc_keypair();
|
||||
let (bob_enc_secret, bob_enc_public) = crypto::generate_enc_keypair();
|
||||
|
||||
let bob_docs = root.path().join("bob-docs");
|
||||
fs::create_dir_all(&bob_docs).unwrap();
|
||||
fs::write(bob_docs.join("doc.txt"), "confidential rust document").unwrap();
|
||||
let alice_config = node_config(
|
||||
&root.path().join("alice"),
|
||||
"alice",
|
||||
"alice.frx.example",
|
||||
&relay_url,
|
||||
&root.path().join("registry.json"),
|
||||
&ma.public_hex(),
|
||||
&alice_enc_secret,
|
||||
);
|
||||
let bob_config = node_config(
|
||||
&root.path().join("bob"),
|
||||
"bob",
|
||||
"bob.frx.example",
|
||||
&relay_url,
|
||||
&root.path().join("registry.json"),
|
||||
&ma.public_hex(),
|
||||
&bob_enc_secret,
|
||||
);
|
||||
save_registry(
|
||||
root.path(),
|
||||
&ma,
|
||||
vec![
|
||||
member_with_enc(
|
||||
"alice.frx.example",
|
||||
&alice_config.load_key().unwrap(),
|
||||
&alice_enc_public,
|
||||
),
|
||||
member_with_enc(
|
||||
"bob.frx.example",
|
||||
&bob_config.load_key().unwrap(),
|
||||
&bob_enc_public,
|
||||
),
|
||||
],
|
||||
);
|
||||
{
|
||||
let index = LocalIndex::open(&bob_config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &bob_docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob = Node::start(bob_config).await.unwrap();
|
||||
let alice = Node::start(alice_config).await.unwrap();
|
||||
|
||||
let (_status, _raw, value) = ask(&client(), &alice.addr.to_string(), "rust", 5).await;
|
||||
let responses = value.get("responses").and_then(Value::as_array).unwrap();
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0]
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len),
|
||||
Some(1),
|
||||
"alice failed to decrypt the response: {value}"
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,7 @@ fn member(id: &str, key: &Keypair, class: &str) -> RegistryMember {
|
||||
not_before: 0,
|
||||
not_after: None,
|
||||
}],
|
||||
enc_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ fn member_entry(
|
||||
not_before,
|
||||
not_after,
|
||||
}],
|
||||
enc_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
mod common;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{
|
||||
challenge, client, publish, query_envelope, register, spawn_relay, spawn_relay_with_capacity,
|
||||
};
|
||||
use frxd::crypto::Keypair;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
async fn open_stream(http: &reqwest::Client, relay: &str, key: &Keypair) -> reqwest::Response {
|
||||
let member = key.public_hex();
|
||||
let nonce = challenge(http, relay, &member).await.expect("challenge");
|
||||
let sig = key.sign(&frxd::crypto::poll_signing_bytes(&member, &nonce));
|
||||
http.get(format!(
|
||||
"{relay}/v1/stream?member={member}&nonce={nonce}&sig={sig}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn read_until<S>(mut stream: S, needle: &str, timeout: Duration) -> (String, S)
|
||||
where
|
||||
S: futures_util::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
let mut buffer = String::new();
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
let chunk = tokio::time::timeout(remaining, stream.next())
|
||||
.await
|
||||
.expect("timed out waiting for stream data");
|
||||
match chunk {
|
||||
Some(Ok(bytes)) => {
|
||||
buffer.push_str(&String::from_utf8_lossy(&bytes));
|
||||
if buffer.contains(needle) {
|
||||
return (buffer, stream);
|
||||
}
|
||||
}
|
||||
Some(Err(error)) => panic!("stream error: {error}"),
|
||||
None => panic!("stream closed before '{needle}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn stream_delivers_envelopes_live() {
|
||||
let relay = spawn_relay().await;
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
let publisher = Keypair::generate();
|
||||
|
||||
let response = open_stream(&http, &relay, &member).await;
|
||||
assert!(response.status().is_success());
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.contains("text/event-stream")
|
||||
);
|
||||
|
||||
let envelope = query_envelope(&publisher, &publisher.public_hex(), "live stream", 5);
|
||||
assert!(
|
||||
publish(&http, &relay, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
|
||||
let (buffer, _) = read_until(
|
||||
response.bytes_stream(),
|
||||
"live stream",
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert!(buffer.contains("event: envelope"), "{buffer}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn stream_reports_lag_then_recovers() {
|
||||
let relay = spawn_relay_with_capacity(1).await;
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
let publisher = Keypair::generate();
|
||||
register(&http, &relay, &member).await;
|
||||
|
||||
let first = query_envelope(&publisher, &publisher.public_hex(), "first", 5);
|
||||
assert!(publish(&http, &relay, &first).await.status().is_success());
|
||||
let second = query_envelope(&publisher, &publisher.public_hex(), "second", 5);
|
||||
assert!(publish(&http, &relay, &second).await.status().is_success());
|
||||
|
||||
let response = open_stream(&http, &relay, &member).await;
|
||||
assert!(response.status().is_success());
|
||||
let (buffer, stream) = read_until(
|
||||
response.bytes_stream(),
|
||||
"\"missed\"",
|
||||
Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert!(buffer.contains("event: lag"), "{buffer}");
|
||||
|
||||
let third = query_envelope(&publisher, &publisher.public_hex(), "third", 5);
|
||||
assert!(publish(&http, &relay, &third).await.status().is_success());
|
||||
let (buffer, _) = read_until(stream, "third", Duration::from_secs(3)).await;
|
||||
assert!(buffer.contains("event: envelope"), "{buffer}");
|
||||
}
|
||||
Reference in New Issue
Block a user