Skip to content

Commit 5bb92d0

Browse files
mickvandijkeclaude
andcommitted
refactor: embed close group view inside signed PaymentQuote
Move the close_group field from ChunkQuoteResponse::Success into PaymentQuote itself, so it is covered by the quote's ML-DSA-65 signature and cannot be forged. Update validate_close_group_membership to also accept peers from the node's own signed quote, handling routing table churn between quote issuance and PUT arrival. Switch evmlib to git rev a3be57f which adds the close_group field to PaymentQuote and includes it in bytes_for_signing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f6ddea9 commit 5bb92d0

8 files changed

Lines changed: 77 additions & 38 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ saorsa-core = "0.22.0"
2828
saorsa-pqc = "0.5"
2929

3030
# Payment verification - autonomi network lookup + EVM payment
31-
evmlib = "0.8"
31+
evmlib = { git = "https://github.com/WithAutonomi/evmlib", rev = "a3be57fcb3bd4982bc93ad0b58116255d509db28" }
3232
xor_name = "5"
3333

3434
# Caching - LRU cache for verified XorNames

src/ant_protocol/chunk.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -234,17 +234,14 @@ pub enum ChunkQuoteResponse {
234234
/// When `already_stored` is `true` the node already holds this chunk and no
235235
/// payment is required — the client should skip the pay-then-PUT cycle for
236236
/// this address. The quote is still included for informational purposes.
237+
///
238+
/// The close group view is embedded inside the serialized `PaymentQuote`
239+
/// and covered by the quote's ML-DSA-65 signature, so it cannot be forged.
237240
Success {
238-
/// Serialized `PaymentQuote`.
241+
/// Serialized `PaymentQuote` (includes the node's close group view).
239242
quote: Vec<u8>,
240243
/// `true` when the chunk already exists on this node (skip payment).
241244
already_stored: bool,
242-
/// Up to `CLOSE_GROUP_SIZE` peer IDs (raw 32-byte BLAKE3 hashes) this
243-
/// node considers closest to the content address, **excluding itself**
244-
/// (the local node is filtered out by the DHT query). Clients combine
245-
/// these views from multiple nodes to verify close-group quorum before
246-
/// paying.
247-
close_group: Vec<[u8; 32]>,
248245
},
249246
/// Quote generation failed.
250247
Error(ProtocolError),

src/payment/proof.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ mod tests {
136136
timestamp: SystemTime::now(),
137137
price: Amount::from(1u64),
138138
rewards_address: RewardsAddress::new([1u8; 20]),
139+
close_group: vec![],
139140
pub_key: vec![],
140141
signature: vec![],
141142
}

src/payment/quote.rs

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ impl QuoteGenerator {
120120
content: XorName,
121121
data_size: usize,
122122
data_type: u32,
123+
close_group: Vec<[u8; 32]>,
123124
) -> Result<PaymentQuote> {
124125
let sign_fn = self
125126
.sign_fn
@@ -134,9 +135,15 @@ impl QuoteGenerator {
134135
// Convert XorName to xor_name::XorName
135136
let xor_name = xor_name::XorName(content);
136137

137-
// Create bytes for signing (following autonomi's pattern)
138-
let bytes =
139-
PaymentQuote::bytes_for_signing(xor_name, timestamp, &price, &self.rewards_address);
138+
// Create bytes for signing — includes close_group so it's
139+
// cryptographically bound to this quote.
140+
let bytes = PaymentQuote::bytes_for_signing(
141+
xor_name,
142+
timestamp,
143+
&price,
144+
&self.rewards_address,
145+
&close_group,
146+
);
140147

141148
// Sign the bytes
142149
let signature = sign_fn(&bytes);
@@ -152,6 +159,7 @@ impl QuoteGenerator {
152159
price,
153160
pub_key: self.pub_key.clone(),
154161
rewards_address: self.rewards_address,
162+
close_group,
155163
signature,
156164
};
157165

@@ -437,7 +445,7 @@ mod tests {
437445
let generator = create_test_generator();
438446
let content = [42u8; 32];
439447

440-
let quote = generator.create_quote(content, 1024, 0);
448+
let quote = generator.create_quote(content, 1024, 0, vec![]);
441449
assert!(quote.is_ok());
442450

443451
let quote = quote.expect("valid quote");
@@ -450,7 +458,7 @@ mod tests {
450458
let content = [42u8; 32];
451459

452460
let quote = generator
453-
.create_quote(content, 1024, 0)
461+
.create_quote(content, 1024, 0, vec![])
454462
.expect("valid quote");
455463
assert!(verify_quote_content(&quote, &content));
456464

@@ -468,7 +476,7 @@ mod tests {
468476
assert!(!generator.can_sign());
469477

470478
let content = [42u8; 32];
471-
let result = generator.create_quote(content, 1024, 0);
479+
let result = generator.create_quote(content, 1024, 0, vec![]);
472480
assert!(result.is_err());
473481
}
474482

@@ -491,7 +499,7 @@ mod tests {
491499

492500
let content = [7u8; 32];
493501
let quote = generator
494-
.create_quote(content, 2048, 0)
502+
.create_quote(content, 2048, 0, vec![])
495503
.expect("create quote");
496504

497505
// Valid signature should verify
@@ -511,7 +519,7 @@ mod tests {
511519
let content = [42u8; 32];
512520

513521
let quote = generator
514-
.create_quote(content, 1024, 0)
522+
.create_quote(content, 1024, 0, vec![])
515523
.expect("create quote");
516524

517525
// The dummy signer produces a 64-byte fake signature, not a valid
@@ -556,9 +564,15 @@ mod tests {
556564
let content = [10u8; 32];
557565

558566
// All data types produce the same price (price depends on records_stored, not data_type)
559-
let q0 = generator.create_quote(content, 1024, 0).expect("type 0");
560-
let q1 = generator.create_quote(content, 512, 1).expect("type 1");
561-
let q2 = generator.create_quote(content, 256, 2).expect("type 2");
567+
let q0 = generator
568+
.create_quote(content, 1024, 0, vec![])
569+
.expect("type 0");
570+
let q1 = generator
571+
.create_quote(content, 512, 1, vec![])
572+
.expect("type 1");
573+
let q2 = generator
574+
.create_quote(content, 256, 2, vec![])
575+
.expect("type 2");
562576

563577
// All quotes should have a valid price (minimum floor of 1)
564578
assert!(q0.price >= Amount::from(1u64));
@@ -572,7 +586,9 @@ mod tests {
572586
let content = [11u8; 32];
573587

574588
// Price depends on records_stored, not data size
575-
let quote = generator.create_quote(content, 0, 0).expect("zero size");
589+
let quote = generator
590+
.create_quote(content, 0, 0, vec![])
591+
.expect("zero size");
576592
assert!(quote.price >= Amount::from(1u64));
577593
}
578594

@@ -583,7 +599,7 @@ mod tests {
583599

584600
// Price depends on records_stored, not data size
585601
let quote = generator
586-
.create_quote(content, 10_000_000, 0)
602+
.create_quote(content, 10_000_000, 0, vec![])
587603
.expect("large size");
588604
assert!(quote.price >= Amount::from(1u64));
589605
}
@@ -595,6 +611,7 @@ mod tests {
595611
timestamp: SystemTime::now(),
596612
price: Amount::from(1u64),
597613
rewards_address: RewardsAddress::new([0u8; 20]),
614+
close_group: vec![],
598615
pub_key: vec![],
599616
signature: vec![],
600617
};

src/payment/single_node.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ mod tests {
258258
timestamp: SystemTime::now(),
259259
price: Amount::from(1u64),
260260
rewards_address: RewardsAddress::new([rewards_addr_seed; 20]),
261+
close_group: vec![],
261262
pub_key: vec![],
262263
signature: vec![],
263264
}
@@ -456,6 +457,7 @@ mod tests {
456457
timestamp: SystemTime::now(),
457458
price: Amount::from(*price),
458459
rewards_address: RewardsAddress::new([1u8; 20]),
460+
close_group: vec![],
459461
pub_key: vec![],
460462
signature: vec![],
461463
};
@@ -566,6 +568,7 @@ mod tests {
566568
price: Amount::from(*price),
567569
#[allow(clippy::cast_possible_truncation)] // i is always < 7
568570
rewards_address: RewardsAddress::new([i as u8 + 1; 20]),
571+
close_group: vec![],
569572
pub_key: vec![],
570573
signature: vec![],
571574
};
@@ -639,6 +642,7 @@ mod tests {
639642
timestamp: SystemTime::now(),
640643
price,
641644
rewards_address: wallet.address(),
645+
close_group: vec![],
642646
pub_key: vec![],
643647
signature: vec![],
644648
};

src/payment/verifier.rs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -688,10 +688,16 @@ impl PaymentVerifier {
688688

689689
/// Verify that **every** peer in the payment proof is a known close group member.
690690
///
691-
/// Builds the known set from the current DHT close group plus this node
692-
/// itself, then checks that each proof peer (derived via `BLAKE3(pub_key)`)
693-
/// appears in that set. Rejects the proof if ANY peer is unrecognized.
691+
/// Builds the allowed set from:
692+
/// 1. The current DHT close group (from the routing table)
693+
/// 2. This node itself
694+
/// 3. Peers listed in this node's own quote's `close_group` field
694695
///
696+
/// Source (3) handles routing table churn: the close group may have changed
697+
/// between quote issuance and the PUT arriving, but the node's own signed
698+
/// quote captured its view at quote time, so those peers are still valid.
699+
///
700+
/// Rejects the proof if ANY peer is unrecognized across all three sources.
695701
/// Skipped when `local_close_group` is empty (unit tests without DHT).
696702
fn validate_close_group_membership(
697703
&self,
@@ -702,11 +708,23 @@ impl PaymentVerifier {
702708
return Ok(());
703709
}
704710

705-
// Build the known peer set: current DHT close group + this node.
711+
// Build the allowed peer set: current DHT close group + this node.
706712
let mut known_peers: HashSet<[u8; 32]> = local_close_group.iter().copied().collect();
707713
known_peers.insert(self.config.local_peer_id);
708714

709-
// Every proof peer must be in the known set.
715+
// Also allow peers that were in this node's close group view at quote
716+
// time. Find our own quote by matching the local rewards address, then
717+
// add its signed close_group entries to the allowed set.
718+
for (_, quote) in &payment.peer_quotes {
719+
if quote.rewards_address == self.config.local_rewards_address {
720+
for peer_id in &quote.close_group {
721+
known_peers.insert(*peer_id);
722+
}
723+
break;
724+
}
725+
}
726+
727+
// Every proof peer must be in the allowed set.
710728
for (_encoded_peer_id, quote) in &payment.peer_quotes {
711729
let peer_id = peer_id_from_public_key_bytes(&quote.pub_key).map_err(|e| {
712730
Error::Payment(format!("Invalid ML-DSA pub_key in proof quote: {e}"))
@@ -732,7 +750,7 @@ impl PaymentVerifier {
732750
#[allow(clippy::expect_used)]
733751
mod tests {
734752
use super::*;
735-
use ant_evm::EncodedPeerId;
753+
use evmlib::EncodedPeerId;
736754
use saorsa_core::MlDsa65;
737755
use saorsa_pqc::pqc::MlDsaOperations;
738756

@@ -1017,7 +1035,9 @@ mod tests {
10171035
});
10181036

10191037
let content: XorName = [i; 32];
1020-
let quote = generator.create_quote(content, 4096, 0).expect("quote");
1038+
let quote = generator
1039+
.create_quote(content, 4096, 0, vec![])
1040+
.expect("quote");
10211041

10221042
peer_quotes.push((EncodedPeerId::new(rand::random()), quote));
10231043
}
@@ -1063,6 +1083,7 @@ mod tests {
10631083
timestamp: SystemTime::now(),
10641084
price: Amount::from(1u64),
10651085
rewards_address: RewardsAddress::new([1u8; 20]),
1086+
close_group: vec![],
10661087
pub_key: vec![0u8; 64],
10671088
signature: vec![0u8; 64],
10681089
};
@@ -1105,6 +1126,7 @@ mod tests {
11051126
timestamp,
11061127
price: Amount::from(1u64),
11071128
rewards_address,
1129+
close_group: vec![],
11081130
pub_key: vec![0u8; 64],
11091131
signature: vec![0u8; 64],
11101132
}
@@ -2092,9 +2114,7 @@ mod tests {
20922114
SystemTime::now(),
20932115
RewardsAddress::new([1u8; 20]),
20942116
);
2095-
let keypair = libp2p::identity::Keypair::generate_ed25519();
2096-
let peer_id = libp2p::PeerId::from_public_key(&keypair.public());
2097-
let peer_quotes = vec![(EncodedPeerId::from(peer_id), quote)];
2117+
let peer_quotes = vec![(EncodedPeerId::new(rand::random()), quote)];
20982118

20992119
let payment = ProofOfPayment { peer_quotes };
21002120

src/storage/handler.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -365,17 +365,18 @@ impl AntProtocol {
365365

366366
let close_group = self.local_close_group(&request.address).await;
367367

368-
match self
369-
.quote_generator
370-
.create_quote(request.address, data_size_usize, request.data_type)
371-
{
368+
match self.quote_generator.create_quote(
369+
request.address,
370+
data_size_usize,
371+
request.data_type,
372+
close_group,
373+
) {
372374
Ok(quote) => {
373375
// Serialize the quote
374376
match rmp_serde::to_vec(&quote) {
375377
Ok(quote_bytes) => ChunkQuoteResponse::Success {
376378
quote: quote_bytes,
377379
already_stored,
378-
close_group,
379380
},
380381
Err(e) => ChunkQuoteResponse::Error(ProtocolError::QuoteFailed(format!(
381382
"Failed to serialize quote: {e}"

0 commit comments

Comments
 (0)