Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 20 additions & 39 deletions src/replication/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,21 +187,22 @@ pub const AUDIT_TICK_INTERVAL_MAX: Duration = Duration::from_secs(AUDIT_TICK_INT
/// Sized to absorb worst-case global RTT for the audit envelope
/// (the request + response messages are KB-scale, not chunk-scale)
/// plus scheduling jitter. Tokyo↔NY round-trip is ~150ms each way,
/// so 2 seconds comfortably covers cross-continent communication
/// so 4 seconds comfortably covers cross-continent communication
/// for the round-1 proof, whose payload is hashes (KB-scale).
const AUDIT_RESPONSE_FLOOR_SECS: u64 = 2;
const AUDIT_RESPONSE_FLOOR_SECS: u64 = 4;

/// Floor on the round-2 BYTE-challenge deadline.
///
/// Unlike round 1 (KB of hashes), the byte challenge ships up to
/// `MAX_BYTE_CHALLENGE_KEYS` full chunks (2 × 4 MiB = 8 MiB) back over the
/// wire, so the envelope must also cover a cold QUIC handshake, the
/// multi-MiB upload back to the auditor, and a busy honest peer's disk read.
/// The round-1 2 s floor (sized for a hashes-only reply) is too tight here —
/// the §4 finding. 5 s matches the cross-continent-RTT + handshake + 8 MiB
/// transfer budget while keeping a relay that must fetch the bytes over a
/// residential link outside it (the scaled term adds the per-byte estimate on
/// top). Mirrors main's more generous byte-round base.
/// The round-1 4 s floor is still sized for a hashes-only reply; round 2 needs
/// a larger base for the §4 byte-serving envelope. 5 s matches the
/// cross-continent-RTT + handshake + 8 MiB transfer budget while keeping a relay
/// that must fetch the bytes over a residential link outside it (the scaled
/// term adds the per-byte estimate on top). Mirrors main's more generous
/// byte-round base.
const BYTE_AUDIT_RESPONSE_FLOOR_SECS: u64 = 5;

/// Conservative honest-responder read throughput, in bytes per second.
Expand Down Expand Up @@ -233,20 +234,6 @@ const AUDIT_HONEST_READ_BPS: u64 = 50 * 1024 * 1024;
/// time (see the §7 note on `audit_response_timeout`).
const AUDIT_RESPONSE_HONEST_MULTIPLIER: u64 = 5;

/// Single-key prune audit response deadline.
///
/// Prune audits ask a peer whether they still hold one specific key
/// they previously claimed. The relay-defence rationale that motivates
/// the tight commitment-bound timeout does NOT apply here: the
/// auditor's own out-of-range hysteresis (`PRUNE_HYSTERESIS_DURATION`,
/// 3 days) already makes "fetch on demand" infeasible as a sustained
/// strategy.
///
/// Sized to comfortably accommodate cold cross-continent QUIC
/// handshake plus scheduling jitter on a busy honest peer answering
/// a single-key challenge: 10 s.
const PRUNE_AUDIT_RESPONSE_SECS: u64 = 10;

/// Maximum duration a peer may claim bootstrap status before penalties apply.
const BOOTSTRAP_CLAIM_GRACE_PERIOD_SECS: u64 = 24 * 60 * 60; // 24 h
/// Maximum duration a peer may claim bootstrap status before penalties apply.
Expand Down Expand Up @@ -403,11 +390,6 @@ pub struct ReplicationConfig {
/// Slack multiplier on the honest-read estimate before
/// declaring an audit timed out.
pub audit_response_honest_multiplier: u64,
/// Single-key prune-audit response deadline. Has its own constant
/// because the relay-defence rationale that motivates the tight
/// commitment-bound budget does not apply to a single-key prune
/// challenge.
pub prune_audit_response_timeout: Duration,
/// Maximum duration a peer may claim bootstrap status.
pub bootstrap_claim_grace_period: Duration,
Comment on lines 390 to 394
/// Minimum continuous out-of-range duration before pruning a key.
Expand Down Expand Up @@ -446,7 +428,6 @@ impl Default for ReplicationConfig {
audit_response_floor: Duration::from_secs(AUDIT_RESPONSE_FLOOR_SECS),
audit_honest_read_bps: AUDIT_HONEST_READ_BPS,
audit_response_honest_multiplier: AUDIT_RESPONSE_HONEST_MULTIPLIER,
prune_audit_response_timeout: Duration::from_secs(PRUNE_AUDIT_RESPONSE_SECS),
bootstrap_claim_grace_period: BOOTSTRAP_CLAIM_GRACE_PERIOD,
prune_hysteresis_duration: PRUNE_HYSTERESIS_DURATION,
verification_request_timeout: VERIFICATION_REQUEST_TIMEOUT,
Expand Down Expand Up @@ -621,9 +602,9 @@ impl ReplicationConfig {
// Resolve the scaled term in MILLISECONDS, not seconds: at the
// byte-round sizes (MAX_BYTE_CHALLENGE_KEYS = 2 → 8 MiB) the per-second
// quotient `multiplied / bps` integer-truncates to 0, leaving only the
// floor (the §4 finding: a 2×4 MiB honest serve under load could blow a
// 2 s budget). Computing in ms keeps the sub-second honest-read estimate
// (e.g. 8 MiB × 5 / 50 MB/s ≈ 840 ms) instead of dropping it.
// floor. The §4 finding was that byte-serving challenges need the
// sub-second honest-read estimate (e.g. 8 MiB × 5 / 50 MB/s ≈ 840 ms)
// instead of dropping it.
let scaled_ms = multiplied.saturating_mul(1000) / bps;
// saturating_add avoids a panic if the floor plus the scaled term would
// overflow `Duration::MAX`.
Expand Down Expand Up @@ -767,19 +748,19 @@ mod tests {

// Scaling now resolves in MILLISECONDS so a sub-second honest read no
// longer truncates to zero (§4). For k=1:
// (4_194_304 × 5 × 1000) / 52_428_800 = 400 ms, + 2 s round-1 floor =
// 2.4 s (previously collapsed to the bare 2 s floor).
assert_eq!(t1, Duration::from_millis(2400));
// (4_194_304 × 5 × 1000) / 52_428_800 = 400 ms, + 4 s round-1 floor =
// 4.4 s.
assert_eq!(t1, Duration::from_millis(4400));

// For k=10: (10 × 4_194_304 × 5 × 1000) / 52_428_800 = 4000 ms scaled,
// + 2 s floor = 6 s. An HDD-backed honest peer at 20 MB/s reads 40 MiB
// + 4 s floor = 8 s. An HDD-backed honest peer at 20 MB/s reads 40 MiB
// in ~2 s, comfortably inside; a relay fetching 40 MiB at 5 MB/s
// residential bandwidth needs ~8 s for the data alone, outside.
assert_eq!(t10, Duration::from_secs(6));
assert_eq!(t10, Duration::from_secs(8));

// For k=100: (100 × 4_194_304 × 5 × 1000) / 52_428_800 = 40_000 ms
// scaled, + 2 s floor = 42 s.
assert_eq!(t100, Duration::from_secs(42));
// scaled, + 4 s floor = 44 s.
assert_eq!(t100, Duration::from_secs(44));
}

#[test]
Expand All @@ -790,7 +771,7 @@ mod tests {
// well below modern HDDs which sustain 80-150 MB/s sequential)
// reads 10 × 4 MiB = 40 MiB in ~2 s. Add 300 ms cross-continent
// RTT, ~10 ms scheduling, ~3 ms ML-DSA sign, and the honest
// envelope is ~2.3 s. The 6 s budget at k=10 leaves >3 s of
// envelope is ~2.3 s. The 8 s budget at k=10 leaves >5 s of
// slack.
let config = ReplicationConfig::default();
let budget = config.audit_response_timeout(10);
Expand All @@ -810,7 +791,7 @@ mod tests {
// read budget fits inside `audit_response_timeout(k)`, while a
// relay attacker fetching k*4MiB over residential bandwidth
// (≈ 5 MB/s realistic for sustained download) does NOT. Spot-
// check this at k=100: honest budget is 42s, relay needs at
// check this at k=100: honest budget is 44s, relay needs at
// least 100 * 4 MiB / 5 MB/s = 80s for the data alone, which
// exceeds the budget.
let config = ReplicationConfig::default();
Expand Down
19 changes: 9 additions & 10 deletions src/replication/pruning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,8 +1151,9 @@ async fn peer_proves_record(
let mut rng = rand::thread_rng();
(rng.gen::<u64>(), rng.gen::<[u8; 32]>())
};
let encoded = encode_prune_audit_challenge(&peer, key, challenge_id, nonce)?;
let Some(decoded) = send_prune_audit_challenge(&peer, &key, encoded, p2p_node, config).await
let (encoded, key_count) = encode_prune_audit_challenge(&peer, key, challenge_id, nonce)?;
let Some(decoded) =
send_prune_audit_challenge(&peer, &key, encoded, key_count, p2p_node, config).await
else {
// No decoded response means a timeout or malformed reply. Prune
// confirmation reuses `AuditChallenge` semantics, so this is an immediate
Expand Down Expand Up @@ -1195,13 +1196,14 @@ fn encode_prune_audit_challenge(
key: XorName,
challenge_id: u64,
nonce: [u8; 32],
) -> Option<Vec<u8>> {
) -> Option<(Vec<u8>, usize)> {
let challenge = AuditChallenge {
challenge_id,
nonce,
challenged_peer_id: *peer.as_bytes(),
keys: vec![key],
};
let key_count = challenge.keys.len();
let msg = ReplicationMessage {
request_id: challenge_id,
body: ReplicationMessageBody::AuditChallenge(challenge),
Expand All @@ -1216,23 +1218,20 @@ fn encode_prune_audit_challenge(
return None;
}
};
Some(encoded)
Some((encoded, key_count))
}

async fn send_prune_audit_challenge(
peer: &PeerId,
key: &XorName,
encoded: Vec<u8>,
key_count: usize,
p2p_node: &Arc<P2PNode>,
config: &ReplicationConfig,
) -> Option<ReplicationMessage> {
let timeout = config.audit_response_timeout(key_count);
let response = match p2p_node
.send_request(
peer,
REPLICATION_PROTOCOL_ID,
encoded,
config.prune_audit_response_timeout,
)
.send_request(peer, REPLICATION_PROTOCOL_ID, encoded, timeout)
.await
{
Ok(response) => response,
Expand Down
Loading