Draft 0.5: identity/registry spec; Phase A signed MA registry, mailbox challenge auth, key rotation, skew rejection

This commit is contained in:
George Coles
2026-09-15 05:45:02 -04:00
parent fb45cfa8cf
commit 559c260639
18 changed files with 1526 additions and 105 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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"
);
+346
View File
@@ -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(
&registry_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,
&registry_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(&registry_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(
&registry_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(
&registry_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,
&registry_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(
&registry_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,
&registry_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(&registry_dir).unwrap();
registry::save_registry(
&registry_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, &registry_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"
);
}