Draft 0.5: identity/registry spec; Phase A signed MA registry, mailbox challenge auth, key rotation, skew rejection
This commit is contained in:
@@ -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