Phase B: identifier+key on the wire, JCS envelope signing (FRX/0.5), registry binding

This commit is contained in:
George Coles
2026-09-15 05:54:52 -04:00
parent 559c260639
commit d30e612be8
12 changed files with 159 additions and 89 deletions
+11 -2
View File
@@ -59,9 +59,18 @@ pub fn free_port() -> u16 {
listener.local_addr().unwrap().port()
}
pub fn query_envelope(key: &Keypair, text: &str, max_results: usize) -> Envelope {
pub fn query_envelope(key: &Keypair, identifier: &str, text: &str, max_results: usize) -> Envelope {
let body = QueryBody::new(text, max_results);
Envelope::new(key, TYPE_QUERY, serde_json::to_value(&body).unwrap())
Envelope::new(
key,
identifier,
TYPE_QUERY,
serde_json::to_value(&body).unwrap(),
)
}
pub fn test_envelope(key: &Keypair, msg_type: &str, body: Value) -> Envelope {
Envelope::new(key, &key.public_hex(), msg_type, body)
}
pub async fn challenge(client: &reqwest::Client, relay_url: &str, member: &str) -> Option<String> {
+2 -2
View File
@@ -103,7 +103,7 @@ async fn multi_relay_deduplicates_and_responds_once() {
let alice = Keypair::generate();
register(&http, &relay_one, &alice).await;
register(&http, &relay_two, &alice).await;
let envelope = query_envelope(&alice, "rust", 5);
let envelope = query_envelope(&alice, &alice.public_hex(), "rust", 5);
assert!(
publish(&http, &relay_one, &envelope)
.await
@@ -192,7 +192,7 @@ async fn poll_returns_queued_batch_in_one_call() {
let member = Keypair::generate();
register(&http, &relay_url, &member).await;
for index in 0..3 {
let envelope = query_envelope(&member, &format!("query {index}"), 5);
let envelope = query_envelope(&member, &member.public_hex(), &format!("query {index}"), 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
+31 -23
View File
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
use common::{
ask, challenge, client, collection, config_for, messages_of_type, poll, poll_messages, publish,
query_envelope, register, spawn_relay, spawn_relay_with_capacity, unicast,
query_envelope, register, spawn_relay, spawn_relay_with_capacity, test_envelope, unicast,
};
use frxd::crypto::Keypair;
use frxd::index::LocalIndex;
@@ -87,7 +87,7 @@ async fn raw_query(
timeout_ms: u64,
) -> Vec<Value> {
register(http, relay_url, asker).await;
let envelope = query_envelope(asker, text, 5);
let envelope = query_envelope(asker, &asker.public_hex(), text, 5);
assert!(
publish(http, relay_url, &envelope)
.await
@@ -100,13 +100,14 @@ async fn raw_query(
#[test]
fn envelope_field_set_is_fixed() {
let key = Keypair::generate();
let envelope = Envelope::new(&key, TYPE_QUERY, json!({"qid": "q", "text": "t"}));
let envelope = test_envelope(&key, TYPE_QUERY, json!({"qid": "q", "text": "t"}));
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(
keys(&value),
BTreeSet::from([
"body".to_string(),
"from".to_string(),
"key".to_string(),
"nonce".to_string(),
"sig".to_string(),
"ts".to_string(),
@@ -337,7 +338,7 @@ async fn entities_are_optional_hints() {
"entities": ["Q999999", "not-a-real-entity"],
"budget": {"max_results": 5}
});
let envelope = Envelope::new(&alice, TYPE_QUERY, body);
let envelope = test_envelope(&alice, TYPE_QUERY, body);
assert!(
publish(&http, &relay_url, &envelope)
.await
@@ -384,7 +385,7 @@ async fn member_directory_filters_senders() {
assert_eq!(trusted.len(), 1, "trusted member got no response");
register(&http, &relay_url, &untrusted).await;
let envelope = query_envelope(&untrusted, "rust", 5);
let envelope = query_envelope(&untrusted, &untrusted.public_hex(), "rust", 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
@@ -424,7 +425,7 @@ async fn rotated_keys_are_accepted_through_previous_listing() {
let http = client();
register(&http, &relay_url, &old_key).await;
let envelope = query_envelope(&old_key, "rust", 5);
let envelope = query_envelope(&old_key, &old_key.public_hex(), "rust", 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
@@ -436,7 +437,7 @@ async fn rotated_keys_are_accepted_through_previous_listing() {
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);
let envelope = query_envelope(&old_key, &old_key.public_hex(), "rust", 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
@@ -453,7 +454,7 @@ async fn stale_envelopes_are_rejected() {
let http = client();
let key = Keypair::generate();
let mut envelope = query_envelope(&key, "stale", 5);
let mut envelope = query_envelope(&key, &key.public_hex(), "stale", 5);
envelope.ts = frxd::crypto::now_ts().saturating_sub(3600);
envelope.sig = key.sign(&frxd::crypto::signing_bytes(&envelope));
assert_eq!(
@@ -461,7 +462,7 @@ async fn stale_envelopes_are_rejected() {
reqwest::StatusCode::BAD_REQUEST
);
let mut response = Envelope::new(
let mut response = test_envelope(
&key,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
@@ -484,12 +485,12 @@ async fn backpressure_is_visible_and_recoverable() {
let publisher = Keypair::generate();
register(&http, &relay_url, &member).await;
let first = query_envelope(&publisher, "one", 5);
let first = query_envelope(&publisher, &publisher.public_hex(), "one", 5);
assert_eq!(
publish(&http, &relay_url, &first).await.status(),
reqwest::StatusCode::ACCEPTED
);
let second = query_envelope(&publisher, "two", 5);
let second = query_envelope(&publisher, &publisher.public_hex(), "two", 5);
let response = publish(&http, &relay_url, &second).await;
assert_eq!(response.status(), reqwest::StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
@@ -511,7 +512,7 @@ async fn backpressure_is_visible_and_recoverable() {
1
);
let third = query_envelope(&publisher, "three", 5);
let third = query_envelope(&publisher, &publisher.public_hex(), "three", 5);
assert_eq!(
publish(&http, &relay_url, &third).await.status(),
reqwest::StatusCode::ACCEPTED
@@ -527,7 +528,7 @@ async fn unicast_is_need_to_know() {
register(&http, &relay_url, &alice).await;
register(&http, &relay_url, &bob).await;
let query = query_envelope(&alice, "rust", 5);
let query = query_envelope(&alice, &alice.public_hex(), "rust", 5);
assert!(
publish(&http, &relay_url, &query)
.await
@@ -535,7 +536,7 @@ async fn unicast_is_need_to_know() {
.is_success()
);
let response = Envelope::new(
let response = test_envelope(
&bob,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
@@ -561,7 +562,7 @@ async fn unicast_to_unknown_member_is_visible() {
let relay_url = spawn_relay().await;
let http = client();
let sender = Keypair::generate();
let response = Envelope::new(
let response = test_envelope(
&sender,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
@@ -580,12 +581,12 @@ async fn tampered_envelopes_are_rejected() {
let relay_url = spawn_relay().await;
let http = client();
let key = Keypair::generate();
let mut envelope = query_envelope(&key, "rust", 5);
let mut envelope = query_envelope(&key, &key.public_hex(), "rust", 5);
envelope.body = json!({"qid": "q1", "text": "forged", "budget": {"max_results": 5}});
let publish_response = publish(&http, &relay_url, &envelope).await;
assert_eq!(publish_response.status(), reqwest::StatusCode::BAD_REQUEST);
let mut response = Envelope::new(
let mut response = test_envelope(
&key,
TYPE_RESPONSE,
json!({"qid": "q1", "results": [], "truncated": false, "more_available": 0, "cursor": null}),
@@ -854,7 +855,7 @@ async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
let sender = Keypair::generate();
register(&http, &relay_url, &bob.node.key).await;
let broadcast = Envelope::new(
let broadcast = test_envelope(
&sender,
TYPE_AGGREGATE,
json!({"period": "2026-03", "sent": 1, "passed": 0}),
@@ -865,7 +866,7 @@ async fn aggregates_are_unicast_only_and_ignored_by_nodes() {
"aggregates must not travel the broadcast channel"
);
let unicast_envelope = Envelope::new(
let unicast_envelope = test_envelope(
&sender,
TYPE_AGGREGATE,
json!({"period": "2026-03", "sent": 1, "passed": 0}),
@@ -927,7 +928,7 @@ async fn duplicate_delivery_is_answered_once() {
let alice = Keypair::generate();
let http = client();
register(&http, &relay_url, &alice).await;
let envelope = query_envelope(&alice, "rust", 5);
let envelope = query_envelope(&alice, &alice.public_hex(), "rust", 5);
assert!(
publish(&http, &relay_url, &envelope)
.await
@@ -1000,7 +1001,8 @@ async fn multiple_responders_merge_with_provenance() {
fn canonical_signing_bytes_are_stable() {
let mut envelope = Envelope {
msg_type: TYPE_QUERY.to_string(),
from: "aa".repeat(32),
from: "alice.frx.example".to_string(),
key: "aa".repeat(32),
ts: 1_700_000_000,
nonce: "00112233445566778899aabbccddeeff".to_string(),
body: json!({"text": "rust", "qid": "q1", "budget": {"max_results": 5}, "entities": []}),
@@ -1011,9 +1013,15 @@ fn canonical_signing_bytes_are_stable() {
json!({"entities": [], "budget": {"max_results": 5}, "qid": "q1", "text": "rust"});
let second = frxd::crypto::signing_bytes(&envelope);
assert_eq!(first, second);
let key = frxd::crypto::Keypair::from_hex(&"00".repeat(32)).unwrap();
let signature = key.sign(&first);
assert_eq!(
String::from_utf8(first).unwrap(),
"FRX/0.4\nquery\naaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n1700000000\n00112233445566778899aabbccddeeff\n{\"budget\":{\"max_results\":5},\"entities\":[],\"qid\":\"q1\",\"text\":\"rust\"}"
"FRX/0.5\n{\"body\":{\"budget\":{\"max_results\":5},\"entities\":[],\"qid\":\"q1\",\"text\":\"rust\"},\"from\":\"alice.frx.example\",\"key\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"nonce\":\"00112233445566778899aabbccddeeff\",\"ts\":1700000000,\"type\":\"query\"}"
);
assert_eq!(
signature,
"2d5df0f85b5ea6b97d5dbc53d316b410c848d52e407583f9740d10889ba5a40fbcf03a930fa7d351689aa657884e3a32e0c6941ff11653a71d75dea58b3e9b09"
);
}
@@ -1033,7 +1041,7 @@ async fn query_body_without_entities_parses() {
let alice = Keypair::generate();
let http = client();
register(&http, &relay_url, &alice).await;
let envelope = Envelope::new(
let envelope = test_envelope(
&alice,
TYPE_QUERY,
json!({"qid": "min", "text": "rust", "budget": {"max_results": 5}}),
+3 -3
View File
@@ -2,9 +2,9 @@ mod common;
use std::fs;
use common::{ask, client, collection, config_for, publish, spawn_relay};
use common::{ask, client, collection, config_for, publish, spawn_relay, test_envelope};
use frxd::index::LocalIndex;
use frxd::message::{EXPOSURE_FULL, Envelope, TYPE_RESPONSE};
use frxd::message::{EXPOSURE_FULL, TYPE_RESPONSE};
use frxd::node::Node;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -106,7 +106,7 @@ async fn two_node_broadcast_query_flow() {
async fn relay_rejects_non_query_broadcasts() {
let relay_url = spawn_relay().await;
let key = frxd::crypto::Keypair::generate();
let envelope = Envelope::new(
let envelope = test_envelope(
&key,
TYPE_RESPONSE,
serde_json::json!({"qid": "x", "results": []}),
+11 -11
View File
@@ -3,11 +3,11 @@ mod common;
use std::collections::BTreeSet;
use std::fs;
use common::{client, collection, config_for, poll, publish, spawn_relay, unicast};
use common::{client, collection, config_for, poll, publish, spawn_relay, test_envelope, unicast};
use frxd::crypto::Keypair;
use frxd::index::LocalIndex;
use frxd::message::{
AggregateBody, Envelope, QueryBody, ResponseItem, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE,
AggregateBody, QueryBody, ResponseItem, TYPE_AGGREGATE, TYPE_QUERY, TYPE_RESPONSE,
build_response,
};
use frxd::node::{self, Node};
@@ -39,7 +39,7 @@ fn no_bounty_winner_selection_or_slashing() {
let query = serde_json::to_value(QueryBody::new("rust", 5)).unwrap();
assert_exact_keys(&query, &["qid", "text", "entities", "budget"]);
assert_exact_keys(query.get("budget").unwrap(), &["max_results"]);
let envelope = Envelope::new(&Keypair::generate(), TYPE_QUERY, query.clone());
let envelope = test_envelope(&Keypair::generate(), TYPE_QUERY, query.clone());
let envelope = serde_json::to_value(&envelope).unwrap();
for value in [&query, &envelope] {
assert_absent_fields(
@@ -64,7 +64,7 @@ async fn no_in_protocol_citation_accounting_or_settlement() {
let key = Keypair::generate();
for msg_type in ["receipt", "settlement", "citation", "invoice"] {
let envelope = Envelope::new(&key, msg_type, serde_json::json!({}));
let envelope = test_envelope(&key, msg_type, serde_json::json!({}));
assert_eq!(
publish(&http, &relay_url, &envelope).await.status(),
reqwest::StatusCode::BAD_REQUEST,
@@ -126,7 +126,7 @@ async fn no_protocol_query_dedup() {
let relay_url = spawn_relay().await;
let http = client();
let key = Keypair::generate();
let envelope = Envelope::new(
let envelope = test_envelope(
&key,
TYPE_QUERY,
serde_json::json!({"qid": "q1", "text": "rust"}),
@@ -225,7 +225,7 @@ async fn no_aggregate_appeals() {
let sender = Keypair::generate();
let member = Keypair::generate();
let dispute = Envelope::new(
let dispute = test_envelope(
&sender,
"dispute",
serde_json::json!({"about": "counter", "reason": "unfair"}),
@@ -245,7 +245,7 @@ async fn no_aggregate_appeals() {
poll(&http, &relay_url, &member, 30).await.status(),
reqwest::StatusCode::NO_CONTENT
);
let aggregate = Envelope::new(
let aggregate = test_envelope(
&sender,
TYPE_AGGREGATE,
serde_json::to_value(AggregateBody {
@@ -304,7 +304,7 @@ async fn no_topic_channels() {
poll(&http, &relay_url, &bob, 30).await.status(),
reqwest::StatusCode::NO_CONTENT
);
let envelope = Envelope::new(
let envelope = test_envelope(
&alice,
TYPE_QUERY,
serde_json::json!({"qid": "q1", "text": "rust"}),
@@ -335,7 +335,7 @@ async fn no_broadcast_responses() {
let http = client();
let key = Keypair::generate();
for msg_type in [TYPE_RESPONSE, "evidence", "commons", "broadcast_response"] {
let envelope = Envelope::new(&key, msg_type, serde_json::json!({}));
let envelope = test_envelope(&key, msg_type, serde_json::json!({}));
assert_eq!(
publish(&http, &relay_url, &envelope).await.status(),
reqwest::StatusCode::BAD_REQUEST,
@@ -382,7 +382,7 @@ async fn no_supply_announce_firehose() {
let http = client();
let key = Keypair::generate();
for msg_type in ["announce", "supply", "documents", "publish"] {
let envelope = Envelope::new(&key, msg_type, serde_json::json!({}));
let envelope = test_envelope(&key, msg_type, serde_json::json!({}));
assert_eq!(
publish(&http, &relay_url, &envelope).await.status(),
reqwest::StatusCode::BAD_REQUEST,
@@ -404,7 +404,7 @@ async fn no_durable_replayable_broadcast_stream() {
let relay_url = spawn_relay().await;
let http = client();
let key = Keypair::generate();
let envelope = Envelope::new(
let envelope = test_envelope(
&key,
TYPE_QUERY,
serde_json::json!({"qid": "q1", "text": "rust"}),
+21 -11
View File
@@ -78,11 +78,12 @@ async fn responses_for(
http: &reqwest::Client,
relay_url: &str,
asker: &Keypair,
identifier: &str,
text: &str,
timeout_ms: u64,
) -> Vec<Value> {
register(http, relay_url, asker).await;
let envelope = query_envelope(asker, text, 5);
let envelope = query_envelope(asker, identifier, text, 5);
assert!(
publish(http, relay_url, &envelope)
.await
@@ -133,16 +134,24 @@ async fn registry_gates_membership_and_revocation_propagates() {
.await;
let http = client();
let accepted = responses_for(&http, &relay_url, &alice, "rust", 700).await;
let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "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;
let denied = responses_for(
&http,
&relay_url,
&stranger,
&stranger.public_hex(),
"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;
let revoked = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 500).await;
assert!(revoked.is_empty(), "revoked member still answered");
tokio::time::sleep(Duration::from_millis(20)).await;
@@ -155,7 +164,8 @@ async fn registry_gates_membership_and_revocation_propagates() {
),
)
.unwrap();
let rolled_back = responses_for(&http, &relay_url, &alice, "rust", 500).await;
let rolled_back =
responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 500).await;
assert!(
rolled_back.is_empty(),
"registry rollback re-admitted a revoked member"
@@ -188,7 +198,7 @@ async fn forged_or_wrong_key_snapshot_closes_the_registry() {
)
.await;
let http = client();
let denied = responses_for(&http, &relay_url, &alice, "rust", 400).await;
let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await;
assert!(
denied.is_empty(),
"snapshot signed by wrong key was trusted"
@@ -226,7 +236,7 @@ async fn expired_key_is_not_authorized() {
)
.await;
let http = client();
let denied = responses_for(&http, &relay_url, &alice, "rust", 400).await;
let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await;
assert!(denied.is_empty(), "expired key was answered");
}
@@ -243,7 +253,7 @@ async fn fail_static_uses_last_validated_snapshot() {
);
let cached_dir = root.path().join("bob-cached");
let mut config = config_with_registry(
let config = config_with_registry(
&cached_dir,
"bob",
&relay_url,
@@ -264,7 +274,7 @@ async fn fail_static_uses_last_validated_snapshot() {
let _bob = Node::start(config.clone()).await.unwrap();
let http = client();
let accepted = responses_for(&http, &relay_url, &alice, "rust", 700).await;
let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 700).await;
assert_eq!(
accepted.len(),
1,
@@ -287,7 +297,7 @@ async fn fail_static_uses_last_validated_snapshot() {
.unwrap();
}
let _bob2 = Node::start(fresh).await.unwrap();
let denied = responses_for(&http, &relay_url, &alice, "rust", 400).await;
let denied = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 400).await;
assert!(
denied.is_empty(),
"unreachable registry without cache must close, not open"
@@ -337,7 +347,7 @@ async fn registry_can_be_served_over_http() {
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;
let accepted = responses_for(&http, &relay_url, &alice, "alice.frx.example", "rust", 900).await;
assert_eq!(
accepted.len(),
1,