Draft 0.5: identity/registry spec; Phase A signed MA registry, mailbox challenge auth, key rotation, skew rejection
This commit is contained in:
@@ -198,11 +198,13 @@ async fn enrichment_members_cannot_send_content() {
|
||||
name: "bob".to_string(),
|
||||
pubkey: bob_full.pubkey.clone(),
|
||||
class: CLASS_ENRICHMENT.to_string(),
|
||||
previous: Vec::new(),
|
||||
},
|
||||
Member {
|
||||
name: "carol".to_string(),
|
||||
pubkey: carol_meta.pubkey.clone(),
|
||||
class: CLASS_ENRICHMENT.to_string(),
|
||||
previous: Vec::new(),
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -256,6 +258,7 @@ async fn source_members_may_send_content() {
|
||||
name: "bob".to_string(),
|
||||
pubkey: bob.pubkey.clone(),
|
||||
class: CLASS_SOURCE.to_string(),
|
||||
previous: Vec::new(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
+141
@@ -183,6 +183,147 @@ fn cli_init_refuses_overwrite_without_force() {
|
||||
run_ok(&forced);
|
||||
}
|
||||
|
||||
fn http_get(port: u16, path: &str) -> String {
|
||||
use std::io::{Read, Write};
|
||||
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
stream
|
||||
.write_all(format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n").as_bytes())
|
||||
.unwrap();
|
||||
let mut out = String::new();
|
||||
stream.read_to_string(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_registry_lifecycle() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("reg");
|
||||
let dir_arg = dir.display().to_string();
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"init".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("MA key"), "{stdout}");
|
||||
assert!(dir.join("registry.json").exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = fs::metadata(dir.join("ma-key.hex"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
}
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"show".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("version 1"));
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"add".to_string(),
|
||||
"alice.frx.example".to_string(),
|
||||
"ab".repeat(32),
|
||||
]);
|
||||
assert!(stdout.contains("added alice.frx.example"));
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"add-key".to_string(),
|
||||
"alice.frx.example".to_string(),
|
||||
"cd".repeat(32),
|
||||
]);
|
||||
assert!(stdout.contains("added key"));
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"list".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("alice.frx.example [source] (2 key(s))"));
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"revoke-key".to_string(),
|
||||
"alice.frx.example".to_string(),
|
||||
"ab".repeat(32),
|
||||
]);
|
||||
assert!(stdout.contains("revoked key"));
|
||||
let stdout = run_ok(&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg.clone(),
|
||||
"list".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("(1 key(s))"));
|
||||
|
||||
let port = common::free_port();
|
||||
let _service = spawn_service(
|
||||
&[
|
||||
"registry".to_string(),
|
||||
"--dir".to_string(),
|
||||
dir_arg,
|
||||
"serve".to_string(),
|
||||
"--listen".to_string(),
|
||||
format!("127.0.0.1:{port}"),
|
||||
],
|
||||
"registry serving",
|
||||
);
|
||||
let body = http_get(port, "/registry.json");
|
||||
assert!(body.contains("\"members\""), "{body}");
|
||||
assert!(body.contains("alice.frx.example"), "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_key_rotation() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let config = root.path().join("frxd.toml");
|
||||
let data = root.path().join("data");
|
||||
let config_arg = config.display().to_string();
|
||||
run_ok(&init_args(&config, "alice", 0, 1, &data));
|
||||
|
||||
let first = run_ok(&[
|
||||
"--config".to_string(),
|
||||
config_arg.clone(),
|
||||
"key".to_string(),
|
||||
"show".to_string(),
|
||||
]);
|
||||
let first = first.trim().to_string();
|
||||
assert_eq!(first.len(), 64);
|
||||
|
||||
let stdout = run_ok(&[
|
||||
"--config".to_string(),
|
||||
config_arg.clone(),
|
||||
"key".to_string(),
|
||||
"rotate".to_string(),
|
||||
]);
|
||||
assert!(stdout.contains("old pubkey"), "{stdout}");
|
||||
assert!(stdout.contains("new pubkey"), "{stdout}");
|
||||
|
||||
let second = run_ok(&[
|
||||
"--config".to_string(),
|
||||
config_arg,
|
||||
"key".to_string(),
|
||||
"show".to_string(),
|
||||
]);
|
||||
assert_ne!(first, second.trim(), "key did not change");
|
||||
assert!(data.join("key.hex.bak").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_full_network_pipeline() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
+31
-6
@@ -13,8 +13,12 @@ pub fn config_for(dir: &Path, name: &str, relay_url: &str) -> Config {
|
||||
let config = Config {
|
||||
node: NodeSection {
|
||||
name: name.to_string(),
|
||||
id: None,
|
||||
listen: "127.0.0.1:0".to_string(),
|
||||
relays: vec![relay_url.to_string()],
|
||||
registry: None,
|
||||
ma_key: None,
|
||||
dev_bootstrap: true,
|
||||
responder: true,
|
||||
},
|
||||
query: QuerySection {
|
||||
@@ -60,17 +64,33 @@ pub fn query_envelope(key: &Keypair, text: &str, max_results: usize) -> Envelope
|
||||
Envelope::new(key, TYPE_QUERY, serde_json::to_value(&body).unwrap())
|
||||
}
|
||||
|
||||
pub async fn register(client: &reqwest::Client, relay_url: &str, member: &str) {
|
||||
poll(client, relay_url, member, 30).await;
|
||||
pub async fn challenge(client: &reqwest::Client, relay_url: &str, member: &str) -> Option<String> {
|
||||
let response = client
|
||||
.get(format!("{relay_url}/v1/challenge?member={member}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
payload
|
||||
.get("nonce")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub async fn register(client: &reqwest::Client, relay_url: &str, key: &Keypair) {
|
||||
poll(client, relay_url, key, 30).await;
|
||||
}
|
||||
|
||||
pub async fn poll_messages(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
member: &str,
|
||||
key: &Keypair,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
let response = poll(client, relay_url, member, timeout_ms).await;
|
||||
let response = poll(client, relay_url, key, timeout_ms).await;
|
||||
if response.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -149,12 +169,17 @@ pub async fn unicast(
|
||||
pub async fn poll(
|
||||
client: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
member: &str,
|
||||
key: &Keypair,
|
||||
timeout_ms: u64,
|
||||
) -> reqwest::Response {
|
||||
let member = key.public_hex();
|
||||
let nonce = challenge(client, relay_url, &member)
|
||||
.await
|
||||
.expect("relay challenge");
|
||||
let sig = key.sign(&frxd::crypto::poll_signing_bytes(&member, &nonce));
|
||||
client
|
||||
.get(format!(
|
||||
"{relay_url}/v1/poll?member={member}&timeout_ms={timeout_ms}"
|
||||
"{relay_url}/v1/poll?member={member}&nonce={nonce}&sig={sig}&timeout_ms={timeout_ms}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -101,8 +101,8 @@ async fn multi_relay_deduplicates_and_responds_once() {
|
||||
|
||||
let http = client();
|
||||
let alice = Keypair::generate();
|
||||
register(&http, &relay_one, &alice.public_hex()).await;
|
||||
register(&http, &relay_two, &alice.public_hex()).await;
|
||||
register(&http, &relay_one, &alice).await;
|
||||
register(&http, &relay_two, &alice).await;
|
||||
let envelope = query_envelope(&alice, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_one, &envelope)
|
||||
@@ -121,7 +121,7 @@ async fn multi_relay_deduplicates_and_responds_once() {
|
||||
let mut responses = Vec::new();
|
||||
while responses.is_empty() && Instant::now() < deadline {
|
||||
for relay in [&relay_one, &relay_two] {
|
||||
let messages = poll_messages(&http, relay, &alice.public_hex(), 200).await;
|
||||
let messages = poll_messages(&http, relay, &alice, 200).await;
|
||||
responses.extend(
|
||||
messages
|
||||
.iter()
|
||||
@@ -190,7 +190,7 @@ async fn poll_returns_queued_batch_in_one_call() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
register(&http, &relay_url, &member.public_hex()).await;
|
||||
register(&http, &relay_url, &member).await;
|
||||
for index in 0..3 {
|
||||
let envelope = query_envelope(&member, &format!("query {index}"), 5);
|
||||
assert!(
|
||||
@@ -200,10 +200,10 @@ async fn poll_returns_queued_batch_in_one_call() {
|
||||
.is_success()
|
||||
);
|
||||
}
|
||||
let messages = poll_messages(&http, &relay_url, &member.public_hex(), 300).await;
|
||||
let messages = poll_messages(&http, &relay_url, &member, 300).await;
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert!(
|
||||
poll_messages(&http, &relay_url, &member.public_hex(), 50)
|
||||
poll_messages(&http, &relay_url, &member, 50)
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
+154
-26
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use common::{
|
||||
ask, client, collection, config_for, messages_of_type, poll, poll_messages, publish,
|
||||
ask, challenge, client, collection, config_for, messages_of_type, poll, poll_messages, publish,
|
||||
query_envelope, register, spawn_relay, spawn_relay_with_capacity, unicast,
|
||||
};
|
||||
use frxd::crypto::Keypair;
|
||||
@@ -63,14 +63,14 @@ async fn start_node_with_corpus(
|
||||
async fn collect_responses(
|
||||
http: &reqwest::Client,
|
||||
relay_url: &str,
|
||||
member: &str,
|
||||
key: &Keypair,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
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, member, 200).await;
|
||||
let messages = poll_messages(http, relay_url, key, 200).await;
|
||||
responses.extend(messages_of_type(&messages, TYPE_RESPONSE));
|
||||
if !responses.is_empty() {
|
||||
break;
|
||||
@@ -86,7 +86,7 @@ async fn raw_query(
|
||||
text: &str,
|
||||
timeout_ms: u64,
|
||||
) -> Vec<Value> {
|
||||
register(http, relay_url, &asker.public_hex()).await;
|
||||
register(http, relay_url, asker).await;
|
||||
let envelope = query_envelope(asker, text, 5);
|
||||
assert!(
|
||||
publish(http, relay_url, &envelope)
|
||||
@@ -94,7 +94,7 @@ async fn raw_query(
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
collect_responses(http, relay_url, &asker.public_hex(), timeout_ms).await
|
||||
collect_responses(http, relay_url, asker, timeout_ms).await
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -330,7 +330,7 @@ async fn entities_are_optional_hints() {
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
register(&http, &relay_url, &alice).await;
|
||||
let body = json!({
|
||||
"qid": "entities-test",
|
||||
"text": "rust",
|
||||
@@ -344,7 +344,7 @@ async fn entities_are_optional_hints() {
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
|
||||
let responses = collect_responses(&http, &relay_url, &alice, 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
assert_eq!(
|
||||
responses[0].pointer("/body/qid").and_then(Value::as_str),
|
||||
@@ -367,6 +367,7 @@ async fn member_directory_filters_senders() {
|
||||
name: "alice".to_string(),
|
||||
pubkey: alice.public_hex(),
|
||||
class: frxd::config::CLASS_SOURCE.to_string(),
|
||||
previous: Vec::new(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -382,7 +383,7 @@ async fn member_directory_filters_senders() {
|
||||
let trusted = raw_query(&http, &relay_url, &alice, "rust", 700).await;
|
||||
assert_eq!(trusted.len(), 1, "trusted member got no response");
|
||||
|
||||
register(&http, &relay_url, &untrusted.public_hex()).await;
|
||||
register(&http, &relay_url, &untrusted).await;
|
||||
let envelope = query_envelope(&untrusted, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
@@ -390,17 +391,98 @@ async fn member_directory_filters_senders() {
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let denied = collect_responses(&http, &relay_url, &untrusted.public_hex(), 400).await;
|
||||
let denied = collect_responses(&http, &relay_url, &untrusted, 400).await;
|
||||
assert!(denied.is_empty(), "untrusted member received a response");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn rotated_keys_are_accepted_through_previous_listing() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let old_key = Keypair::generate();
|
||||
let new_key = Keypair::generate();
|
||||
|
||||
let docs = corpus_dir(root.path(), "bob", &[("doc.txt", "rotation rust document")]);
|
||||
let config = config_for(&root.path().join("bob"), "bob", &relay_url);
|
||||
let listing = |previous: Vec<String>| {
|
||||
vec![frxd::config::Member {
|
||||
name: "carol".to_string(),
|
||||
pubkey: new_key.public_hex(),
|
||||
class: frxd::config::CLASS_SOURCE.to_string(),
|
||||
previous,
|
||||
}]
|
||||
};
|
||||
frxd::config::save_members(&config.members_path(), &listing(vec![old_key.public_hex()]))
|
||||
.unwrap();
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob = Node::start(config.clone()).await.unwrap();
|
||||
|
||||
let http = client();
|
||||
register(&http, &relay_url, &old_key).await;
|
||||
let envelope = query_envelope(&old_key, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let accepted = collect_responses(&http, &relay_url, &old_key, 700).await;
|
||||
assert_eq!(accepted.len(), 1, "listed previous key was rejected");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
frxd::config::save_members(&config.members_path(), &listing(Vec::new())).unwrap();
|
||||
let envelope = query_envelope(&old_key, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
.await
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let revoked = collect_responses(&http, &relay_url, &old_key, 500).await;
|
||||
assert!(revoked.is_empty(), "revoked key was still accepted");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn stale_envelopes_are_rejected() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let http = client();
|
||||
let key = Keypair::generate();
|
||||
|
||||
let mut envelope = query_envelope(&key, "stale", 5);
|
||||
envelope.ts = frxd::crypto::now_ts().saturating_sub(3600);
|
||||
envelope.sig = key.sign(&frxd::crypto::signing_bytes(&envelope));
|
||||
assert_eq!(
|
||||
publish(&http, &relay_url, &envelope).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
let mut response = Envelope::new(
|
||||
&key,
|
||||
TYPE_RESPONSE,
|
||||
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
|
||||
);
|
||||
response.ts = frxd::crypto::now_ts().saturating_sub(3600);
|
||||
response.sig = key.sign(&frxd::crypto::signing_bytes(&response));
|
||||
assert_eq!(
|
||||
unicast(&http, &relay_url, &key.public_hex(), &response)
|
||||
.await
|
||||
.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn backpressure_is_visible_and_recoverable() {
|
||||
let relay_url = spawn_relay_with_capacity(1).await;
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
let publisher = Keypair::generate();
|
||||
register(&http, &relay_url, &member.public_hex()).await;
|
||||
register(&http, &relay_url, &member).await;
|
||||
|
||||
let first = query_envelope(&publisher, "one", 5);
|
||||
assert_eq!(
|
||||
@@ -419,13 +501,11 @@ async fn backpressure_is_visible_and_recoverable() {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
poll_messages(&http, &relay_url, &member.public_hex(), 300)
|
||||
.await
|
||||
.len(),
|
||||
poll_messages(&http, &relay_url, &member, 300).await.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
poll_messages(&http, &relay_url, &publisher.public_hex(), 300)
|
||||
poll_messages(&http, &relay_url, &publisher, 300)
|
||||
.await
|
||||
.len(),
|
||||
1
|
||||
@@ -444,8 +524,8 @@ async fn unicast_is_need_to_know() {
|
||||
let http = client();
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
register(&http, &relay_url, &bob.public_hex()).await;
|
||||
register(&http, &relay_url, &alice).await;
|
||||
register(&http, &relay_url, &bob).await;
|
||||
|
||||
let query = query_envelope(&alice, "rust", 5);
|
||||
assert!(
|
||||
@@ -467,9 +547,9 @@ async fn unicast_is_need_to_know() {
|
||||
reqwest::StatusCode::OK
|
||||
);
|
||||
|
||||
let alice_messages = poll_messages(&http, &relay_url, &alice.public_hex(), 300).await;
|
||||
let alice_messages = poll_messages(&http, &relay_url, &alice, 300).await;
|
||||
assert_eq!(messages_of_type(&alice_messages, TYPE_RESPONSE).len(), 1);
|
||||
let bob_messages = poll_messages(&http, &relay_url, &bob.public_hex(), 300).await;
|
||||
let bob_messages = poll_messages(&http, &relay_url, &bob, 300).await;
|
||||
assert!(
|
||||
messages_of_type(&bob_messages, TYPE_RESPONSE).is_empty(),
|
||||
"unicast response leaked to another member"
|
||||
@@ -516,10 +596,58 @@ async fn tampered_envelopes_are_rejected() {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn invalid_poll_member_is_rejected() {
|
||||
async fn poll_requires_proof_of_key() {
|
||||
let relay_url = spawn_relay().await;
|
||||
let response = poll(&client(), &relay_url, "not-hex-at-all", 30).await;
|
||||
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
|
||||
let http = client();
|
||||
let member = Keypair::generate();
|
||||
let imposter = Keypair::generate();
|
||||
|
||||
assert!(
|
||||
challenge(&http, &relay_url, "not-hex-at-all")
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let nonce = challenge(&http, &relay_url, &member.public_hex())
|
||||
.await
|
||||
.unwrap();
|
||||
let forged = imposter.sign(&frxd::crypto::poll_signing_bytes(
|
||||
&member.public_hex(),
|
||||
&nonce,
|
||||
));
|
||||
let response = http
|
||||
.get(format!(
|
||||
"{relay_url}/v1/poll?member={}&nonce={nonce}&sig={forged}&timeout_ms=30",
|
||||
member.public_hex()
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
|
||||
|
||||
let response = poll(&http, &relay_url, &member, 30).await;
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let nonce = challenge(&http, &relay_url, &member.public_hex())
|
||||
.await
|
||||
.unwrap();
|
||||
let sig = member.sign(&frxd::crypto::poll_signing_bytes(
|
||||
&member.public_hex(),
|
||||
&nonce,
|
||||
));
|
||||
let url = format!(
|
||||
"{relay_url}/v1/poll?member={}&nonce={nonce}&sig={sig}&timeout_ms=30",
|
||||
member.public_hex()
|
||||
);
|
||||
assert_eq!(
|
||||
http.get(&url).send().await.unwrap().status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
http.get(&url).send().await.unwrap().status(),
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
"challenge nonce must be single-use"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -724,7 +852,7 @@ async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
|
||||
.await;
|
||||
let http = client();
|
||||
let sender = Keypair::generate();
|
||||
register(&http, &relay_url, &bob.pubkey).await;
|
||||
register(&http, &relay_url, &bob.node.key).await;
|
||||
|
||||
let broadcast = Envelope::new(
|
||||
&sender,
|
||||
@@ -798,7 +926,7 @@ async fn duplicate_delivery_is_answered_once() {
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
register(&http, &relay_url, &alice).await;
|
||||
let envelope = query_envelope(&alice, "rust", 5);
|
||||
assert!(
|
||||
publish(&http, &relay_url, &envelope)
|
||||
@@ -812,7 +940,7 @@ async fn duplicate_delivery_is_answered_once() {
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
|
||||
let responses = collect_responses(&http, &relay_url, &alice, 700).await;
|
||||
assert_eq!(
|
||||
responses.len(),
|
||||
1,
|
||||
@@ -904,7 +1032,7 @@ async fn query_body_without_entities_parses() {
|
||||
.await;
|
||||
let alice = Keypair::generate();
|
||||
let http = client();
|
||||
register(&http, &relay_url, &alice.public_hex()).await;
|
||||
register(&http, &relay_url, &alice).await;
|
||||
let envelope = Envelope::new(
|
||||
&alice,
|
||||
TYPE_QUERY,
|
||||
@@ -916,7 +1044,7 @@ async fn query_body_without_entities_parses() {
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let responses = collect_responses(&http, &relay_url, &alice.public_hex(), 700).await;
|
||||
let responses = collect_responses(&http, &relay_url, &alice, 700).await;
|
||||
assert_eq!(responses.len(), 1);
|
||||
}
|
||||
|
||||
|
||||
+7
-15
@@ -143,7 +143,7 @@ async fn no_protocol_query_dedup() {
|
||||
.status()
|
||||
.is_success()
|
||||
);
|
||||
let response = poll(&http, &relay_url, &key.public_hex(), 300).await;
|
||||
let response = poll(&http, &relay_url, &key, 300).await;
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
@@ -242,9 +242,7 @@ async fn no_aggregate_appeals() {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &member.public_hex(), 30)
|
||||
.await
|
||||
.status(),
|
||||
poll(&http, &relay_url, &member, 30).await.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
let aggregate = Envelope::new(
|
||||
@@ -299,15 +297,11 @@ async fn no_topic_channels() {
|
||||
let alice = Keypair::generate();
|
||||
let bob = Keypair::generate();
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &alice.public_hex(), 30)
|
||||
.await
|
||||
.status(),
|
||||
poll(&http, &relay_url, &alice, 30).await.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &bob.public_hex(), 30)
|
||||
.await
|
||||
.status(),
|
||||
poll(&http, &relay_url, &bob, 30).await.status(),
|
||||
reqwest::StatusCode::NO_CONTENT
|
||||
);
|
||||
let envelope = Envelope::new(
|
||||
@@ -322,7 +316,7 @@ async fn no_topic_channels() {
|
||||
.is_success()
|
||||
);
|
||||
for member in [&alice, &bob] {
|
||||
let response = poll(&http, &relay_url, &member.public_hex(), 300).await;
|
||||
let response = poll(&http, &relay_url, &member, 300).await;
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
@@ -422,7 +416,7 @@ async fn no_durable_replayable_broadcast_stream() {
|
||||
.is_success()
|
||||
);
|
||||
|
||||
let response = poll(&http, &relay_url, &key.public_hex(), 300).await;
|
||||
let response = poll(&http, &relay_url, &key, 300).await;
|
||||
let payload: Value = response.json().await.unwrap();
|
||||
assert_eq!(
|
||||
payload
|
||||
@@ -433,9 +427,7 @@ async fn no_durable_replayable_broadcast_stream() {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
poll(&http, &relay_url, &key.public_hex(), 50)
|
||||
.await
|
||||
.status(),
|
||||
poll(&http, &relay_url, &key, 50).await.status(),
|
||||
reqwest::StatusCode::NO_CONTENT,
|
||||
"relay replayed a drained message"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
mod common;
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::routing::get;
|
||||
use axum::{Json, Router};
|
||||
use common::{
|
||||
client, collection, config_for, poll_messages, publish, query_envelope, register, spawn_relay,
|
||||
};
|
||||
use frxd::crypto::{Keypair, now_ts};
|
||||
use frxd::index::LocalIndex;
|
||||
use frxd::message::{EXPOSURE_FULL, TYPE_RESPONSE};
|
||||
use frxd::node::{Node, NodeHandle};
|
||||
use frxd::registry::{self, KeyEntry, RegistryDoc, RegistryMember, SignedRegistry};
|
||||
use serde_json::Value;
|
||||
|
||||
fn member_entry(
|
||||
id: &str,
|
||||
key: &Keypair,
|
||||
class: &str,
|
||||
not_before: u64,
|
||||
not_after: Option<u64>,
|
||||
) -> RegistryMember {
|
||||
RegistryMember {
|
||||
id: id.to_string(),
|
||||
class: class.to_string(),
|
||||
keys: vec![KeyEntry {
|
||||
key: key.public_hex(),
|
||||
not_before,
|
||||
not_after,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn ma_registry(ma: &Keypair, members: Vec<RegistryMember>, version: u64) -> SignedRegistry {
|
||||
registry::sign_registry(
|
||||
RegistryDoc {
|
||||
version,
|
||||
issued_at: now_ts(),
|
||||
ma_key: String::new(),
|
||||
members,
|
||||
},
|
||||
ma,
|
||||
)
|
||||
}
|
||||
|
||||
fn config_with_registry(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
relay_url: &str,
|
||||
registry: &str,
|
||||
ma_key: &str,
|
||||
) -> frxd::config::Config {
|
||||
let mut config = config_for(dir, name, relay_url);
|
||||
config.node.registry = Some(registry.to_string());
|
||||
config.node.ma_key = Some(ma_key.to_string());
|
||||
config.node.dev_bootstrap = false;
|
||||
config
|
||||
}
|
||||
|
||||
async fn bob_with_docs(root: &Path, relay_url: &str, registry: &str, ma_key: &str) -> NodeHandle {
|
||||
let docs = root.join("bob-docs");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
fs::write(docs.join("doc.txt"), "registry rust document").unwrap();
|
||||
let config = config_with_registry(&root.join("bob"), "bob", relay_url, registry, ma_key);
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
Node::start(config).await.unwrap()
|
||||
}
|
||||
|
||||
async fn responses_for(
|
||||
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, 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 registry_gates_membership_and_revocation_propagates() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
let alice = Keypair::generate();
|
||||
let registry_path = root.path().join("registry.json");
|
||||
registry::save_registry(
|
||||
®istry_path,
|
||||
&ma_registry(
|
||||
&ma,
|
||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
||||
1,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _bob = bob_with_docs(
|
||||
root.path(),
|
||||
&relay_url,
|
||||
®istry_path.display().to_string(),
|
||||
&ma.public_hex(),
|
||||
)
|
||||
.await;
|
||||
let http = client();
|
||||
|
||||
let accepted = responses_for(&http, &relay_url, &alice, "rust", 700).await;
|
||||
assert_eq!(accepted.len(), 1, "listed member got no response");
|
||||
|
||||
let stranger = Keypair::generate();
|
||||
let denied = responses_for(&http, &relay_url, &stranger, "rust", 400).await;
|
||||
assert!(denied.is_empty(), "unlisted key was answered");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
registry::save_registry(®istry_path, &ma_registry(&ma, Vec::new(), 2)).unwrap();
|
||||
let revoked = responses_for(&http, &relay_url, &alice, "rust", 500).await;
|
||||
assert!(revoked.is_empty(), "revoked member still answered");
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
registry::save_registry(
|
||||
®istry_path,
|
||||
&ma_registry(
|
||||
&ma,
|
||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
||||
1,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let rolled_back = responses_for(&http, &relay_url, &alice, "rust", 500).await;
|
||||
assert!(
|
||||
rolled_back.is_empty(),
|
||||
"registry rollback re-admitted a revoked member"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn forged_or_wrong_key_snapshot_closes_the_registry() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
let attacker = Keypair::generate();
|
||||
let alice = Keypair::generate();
|
||||
let registry_path = root.path().join("registry.json");
|
||||
registry::save_registry(
|
||||
®istry_path,
|
||||
&ma_registry(
|
||||
&attacker,
|
||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
||||
1,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _bob = bob_with_docs(
|
||||
root.path(),
|
||||
&relay_url,
|
||||
®istry_path.display().to_string(),
|
||||
&ma.public_hex(),
|
||||
)
|
||||
.await;
|
||||
let http = client();
|
||||
let denied = responses_for(&http, &relay_url, &alice, "rust", 400).await;
|
||||
assert!(
|
||||
denied.is_empty(),
|
||||
"snapshot signed by wrong key was trusted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn expired_key_is_not_authorized() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
let alice = Keypair::generate();
|
||||
let registry_path = root.path().join("registry.json");
|
||||
registry::save_registry(
|
||||
®istry_path,
|
||||
&ma_registry(
|
||||
&ma,
|
||||
vec![member_entry(
|
||||
"alice.frx.example",
|
||||
&alice,
|
||||
"source",
|
||||
0,
|
||||
Some(now_ts().saturating_sub(1)),
|
||||
)],
|
||||
1,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _bob = bob_with_docs(
|
||||
root.path(),
|
||||
&relay_url,
|
||||
®istry_path.display().to_string(),
|
||||
&ma.public_hex(),
|
||||
)
|
||||
.await;
|
||||
let http = client();
|
||||
let denied = responses_for(&http, &relay_url, &alice, "rust", 400).await;
|
||||
assert!(denied.is_empty(), "expired key was answered");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn fail_static_uses_last_validated_snapshot() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
let alice = Keypair::generate();
|
||||
let snapshot = ma_registry(
|
||||
&ma,
|
||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
||||
1,
|
||||
);
|
||||
|
||||
let cached_dir = root.path().join("bob-cached");
|
||||
let mut config = config_with_registry(
|
||||
&cached_dir,
|
||||
"bob",
|
||||
&relay_url,
|
||||
"http://127.0.0.1:1/registry.json",
|
||||
&ma.public_hex(),
|
||||
);
|
||||
fs::create_dir_all(config.data_dir()).unwrap();
|
||||
registry::save_registry(&config.registry_cache_path(), &snapshot).unwrap();
|
||||
let docs = root.path().join("bob-docs");
|
||||
fs::create_dir_all(&docs).unwrap();
|
||||
fs::write(docs.join("doc.txt"), "registry rust document").unwrap();
|
||||
{
|
||||
let index = LocalIndex::open(&config.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob = Node::start(config.clone()).await.unwrap();
|
||||
|
||||
let http = client();
|
||||
let accepted = responses_for(&http, &relay_url, &alice, "rust", 700).await;
|
||||
assert_eq!(
|
||||
accepted.len(),
|
||||
1,
|
||||
"cached snapshot should keep last-known-good membership"
|
||||
);
|
||||
drop(_bob);
|
||||
|
||||
let fresh_dir = root.path().join("bob-fresh");
|
||||
let fresh = config_with_registry(
|
||||
&fresh_dir,
|
||||
"bob2",
|
||||
&relay_url,
|
||||
"http://127.0.0.1:1/registry.json",
|
||||
&ma.public_hex(),
|
||||
);
|
||||
{
|
||||
let index = LocalIndex::open(&fresh.index_dir()).unwrap();
|
||||
index
|
||||
.add_collection(&collection("docs", &docs, true, EXPOSURE_FULL))
|
||||
.unwrap();
|
||||
}
|
||||
let _bob2 = Node::start(fresh).await.unwrap();
|
||||
let denied = responses_for(&http, &relay_url, &alice, "rust", 400).await;
|
||||
assert!(
|
||||
denied.is_empty(),
|
||||
"unreachable registry without cache must close, not open"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn registry_can_be_served_over_http() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let relay_url = spawn_relay().await;
|
||||
let ma = Keypair::generate();
|
||||
let alice = Keypair::generate();
|
||||
let registry_dir = root.path().join("ma");
|
||||
fs::create_dir_all(®istry_dir).unwrap();
|
||||
registry::save_registry(
|
||||
®istry_dir.join("registry.json"),
|
||||
&ma_registry(
|
||||
&ma,
|
||||
vec![member_entry("alice.frx.example", &alice, "source", 0, None)],
|
||||
1,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let state: PathBuf = registry_dir.clone();
|
||||
let handler_state = state.clone();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/registry.json",
|
||||
get(move || {
|
||||
let state = handler_state.clone();
|
||||
async move {
|
||||
Json(
|
||||
registry::load_registry(&state.join("registry.json"))
|
||||
.expect("registry file"),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.with_state(state);
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
});
|
||||
let registry_url = format!("http://{addr}/registry.json");
|
||||
|
||||
let _bob = bob_with_docs(root.path(), &relay_url, ®istry_url, &ma.public_hex()).await;
|
||||
let http = client();
|
||||
let accepted = responses_for(&http, &relay_url, &alice, "rust", 900).await;
|
||||
assert_eq!(
|
||||
accepted.len(),
|
||||
1,
|
||||
"http-served registry did not authorize member"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user