From c96cb7d65dd524d9b6cfa23d2b28214256d59e2b Mon Sep 17 00:00:00 2001 From: stevenmih Date: Fri, 11 Sep 2026 13:02:16 -0700 Subject: [PATCH 1/2] feat(mesh): carry a signed ClaimedLogHead on PeerAnnouncement (rebased, squashed for PR #1709) Squashed rebase of the 5-commit up-peer-root-v2 lineage (feat: carry optional signed attested-log head; bound bytes + rename attested->claimed; give log_id its own byte-length constant; spell out signing contract, fix absence semantics; fix signature_algorithm canonicalization and empty-value semantics) onto current origin/main (f219b03ab, which now carries #1673's itemized `memory: Option` field on PeerAnnouncement). Squashed rather than replayed commit-by-commit because each of the 5 original commits incrementally rewrites the SAME test region (protocol/tests/announcements.rs) that #1673 also touched, so a commit-by-commit rebase produced 4 cascading conflicts against manually-resolved intermediate states. A single squash-merge against origin/main produces one clean conflict instead; resolved by keeping both #1673's four new AdvertisedMemory tests and this branch's ClaimedLogHead helper + test suite, and adding the new `memory: None` field this branch's two exhaustive PeerAnnouncement literals were missing (the struct has no Default derive). cargo check -p mesh-llm-host-runtime -p mesh-llm-protocol --tests: clean. Full workspace clippy/fmt/quality-contracts gate still to run via scripts/ci-local.sh (Docker, pinned toolchain) before push. Signed-off-by: stevenmih --- .../src/mesh/announcements.rs | 11 + crates/mesh-llm-host-runtime/src/mesh/mod.rs | 4 +- .../src/mesh/peer_state.rs | 20 + .../src/mesh/tests/admission/helpers.rs | 4 + .../src/mesh/tests/admission/requirements.rs | 9 + .../src/mesh/tests/gossip.rs | 4 + .../src/mesh/tests/peer_state.rs | 14 + .../src/mesh/tests/protocol_frames.rs | 1 + .../src/protocol/convert.rs | 82 ++++ .../mesh-llm-host-runtime/src/protocol/mod.rs | 2 + .../src/protocol/tests/announcements.rs | 370 ++++++++++++++++++ .../src/protocol/tests/mesh_timestamps.rs | 4 + crates/mesh-llm-protocol/proto/node.proto | 74 ++++ crates/mesh-llm-protocol/src/proto/node.rs | 81 ++++ 14 files changed, 678 insertions(+), 2 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/mesh/announcements.rs b/crates/mesh-llm-host-runtime/src/mesh/announcements.rs index cf44e81947..305a5ac3fe 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/announcements.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/announcements.rs @@ -467,6 +467,8 @@ impl Node { }) } + /// Builds the gossip announcement re-broadcast on behalf of an already + /// admitted peer, from that peer's locally tracked state. pub(crate) fn announcement_from_peer(peer: &PeerInfo) -> PeerAnnouncement { let latency = peer.display_latency(); PeerAnnouncement { @@ -518,6 +520,10 @@ impl Node { latency_age_ms: Some(latency.age_ms), latency_observer_id: latency.observer_id, inference_admission_state: peer.inference_admission_state, + // No live-mesh claimed-log-head state is tracked on `PeerInfo` yet — a + // rebroadcast of a peer we already admitted carries no opinion on + // its claimed log head. + claimed_log_head: None, } } @@ -533,6 +539,8 @@ impl Node { }) } + /// Builds this node's own gossip announcement from freshly collected + /// local data. pub(crate) fn build_local_announcement(&self, data: LocalAnnouncementData) -> PeerAnnouncement { PeerAnnouncement { addr: self.endpoint_addr_for_advertisement(), @@ -582,6 +590,9 @@ impl Node { latency_age_ms: None, latency_observer_id: None, inference_admission_state: data.inference_admission_state, + // No local claimed-log-head source is wired yet — this node never + // advertises its own until a companion process is plumbed in. + claimed_log_head: None, } } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/mod.rs b/crates/mesh-llm-host-runtime/src/mesh/mod.rs index 27e8fa3e4f..9191223c12 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/mod.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/mod.rs @@ -162,8 +162,8 @@ pub use node::{ }; pub(crate) use node::{PeerDownReport, peer_down_endpoint_id}; pub(crate) use peer_state::{ - ControlListenerLifecycle, DEAD_PEER_TTL, MeshState, PEER_DOWN_REPORTER_COOLDOWN_SECS, - PEER_STALE_SECS, resolve_peer_leaving, + ClaimedLogHead, ControlListenerLifecycle, DEAD_PEER_TTL, MeshState, + PEER_DOWN_REPORTER_COOLDOWN_SECS, PEER_STALE_SECS, resolve_peer_leaving, }; #[expect( unused_imports, diff --git a/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs index a07b27caa0..7eaf2b8316 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/peer_state.rs @@ -166,6 +166,26 @@ pub struct PeerAnnouncement { pub(crate) latency_age_ms: Option, pub(crate) latency_observer_id: Option, pub(crate) inference_admission_state: Option, + /// An optional, self-reported claim this peer MAY advertise about the + /// head of its own append-only history. Carried opaquely; never verified + /// by mesh-llm. + pub(crate) claimed_log_head: Option, +} + +/// A peer's latest self-reported claim about the head of its append-only log +/// — see `ClaimedLogHead` in `node.proto` for the wire shape and the +/// signing-scope note. Carried opaquely: mesh-llm never verifies +/// `claimed_signature` itself, hence the name — a consumer that does verify +/// it may define its own `VerifiedLogHead` type; none exists here. `pub(crate)` +/// to match `PeerAnnouncement::claimed_log_head`, which is also `pub(crate)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClaimedLogHead { + pub(crate) log_id: String, + pub(crate) size: u64, + pub(crate) root: Vec, + pub(crate) timestamp_unix_ms: u64, + pub(crate) claimed_signature: Vec, + pub(crate) signature_algorithm: String, } /// A single direct RTT measurement (e.g. from gossip exchange). diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs index 6500b10cc1..7a99ed0d3a 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/helpers.rs @@ -374,6 +374,9 @@ pub(super) async fn configure_requirement_node( Ok(()) } +/// Builds a `PeerAnnouncement` fixture carrying the given genesis policy hash +/// and (optionally) a release attestation, for mesh-requirements admission +/// tests. pub(super) fn requirement_peer_announcement( sender_seed: u8, policy: &crate::MeshGenesisPolicy, @@ -428,5 +431,6 @@ pub(super) fn requirement_peer_announcement( latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs index 2bd6cc5c20..824f7289ed 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/admission/requirements.rs @@ -540,6 +540,8 @@ pub(crate) fn assert_requirement_aware_mesh_without_attestation_accepts_valid_di }); } +/// Asserts that a peer whose release attestation is signed by a key outside +/// the mesh's trusted signer set is rejected, not admitted. pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); runtime.block_on(async { @@ -606,6 +608,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.add_peer( @@ -630,6 +633,8 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_untrusted_release_signer }); } +/// Asserts that a peer whose release attestation signature does not verify +/// against its claimed signer is rejected, not admitted. pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestation_signature() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); runtime.block_on(async { @@ -699,6 +704,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestat latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.add_peer( @@ -723,6 +729,8 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_invalid_release_attestat }); } +/// Asserts that a peer advertising a genesis policy hash for a different +/// mesh is rejected, not admitted. pub(crate) fn assert_mesh_requirements_add_peer_rejects_wrong_mesh_id() { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); runtime.block_on(async { @@ -789,6 +797,7 @@ pub(crate) fn assert_mesh_requirements_add_peer_rejects_wrong_mesh_id() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.add_peer( diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs index 56c575fbb4..2fed941301 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/gossip.rs @@ -18,6 +18,9 @@ pub(crate) fn test_addr(seed: u8) -> EndpointAddr { } } +/// Builds a minimal `PeerAnnouncement` fixture with the given +/// `first_joined_mesh_ts`, for gossip tests that don't care about the rest +/// of the announcement's fields. pub(crate) fn test_announcement(ts: Option) -> PeerAnnouncement { PeerAnnouncement { addr: test_addr(0x11), @@ -64,6 +67,7 @@ pub(crate) fn test_announcement(ts: Option) -> PeerAnnouncement { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, } } diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs index 64f0e2bf6d..f888306472 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/peer_state.rs @@ -357,6 +357,9 @@ fn relay_reconnect_controller_applies_cooldown_after_attempt_and_prunes_gone_pee ); } +/// Builds a minimal `PeerAnnouncement` fixture at the given address, for +/// peer-state tests that don't care about the rest of the announcement's +/// fields. fn peer_state_test_announcement(addr: EndpointAddr) -> super::PeerAnnouncement { super::PeerAnnouncement { addr, @@ -403,6 +406,7 @@ fn peer_state_test_announcement(addr: EndpointAddr) -> super::PeerAnnouncement { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, } } @@ -921,6 +925,8 @@ fn control_frame_rejects_oversize_or_bad_generation() { ); } +/// Proves a gossip-frame round trip preserves locally scanned model metadata +/// fields on the announcement. #[test] fn gossip_frame_roundtrip_preserves_scanned_model_metadata() { use crate::proto::node::{CompactModelMetadata, ExpertsSummary}; @@ -1029,6 +1035,7 @@ fn gossip_frame_roundtrip_preserves_scanned_model_metadata() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&local_ann); @@ -1306,6 +1313,8 @@ fn gossip_rejects_sender_id_mismatch_or_invalid_endpoint_len() { ); } +/// Proves a transitively gossiped update refreshes a peer's metadata fields +/// in place, without dropping unrelated state. #[test] fn transitive_peer_update_refreshes_metadata_fields() { use crate::proto::node::CompactModelMetadata; @@ -1391,6 +1400,7 @@ fn transitive_peer_update_refreshes_metadata_fields() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; apply_transitive_ann(&mut existing, &addr, &ann, make_test_endpoint_id(0xee)); @@ -1418,6 +1428,8 @@ fn transitive_peer_update_refreshes_metadata_fields() { assert!(existing.available_model_sizes.is_empty()); } +/// Proves merging a transitively gossiped peer update never discards a +/// richer, already-known direct address in favor of a sparser one. #[test] fn transitive_peer_merge_preserves_richer_direct_address() { use iroh::TransportAddr; @@ -1485,6 +1497,7 @@ fn transitive_peer_merge_preserves_richer_direct_address() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; apply_transitive_ann(&mut existing, &weak_addr, &ann, make_test_endpoint_id(0xee)); @@ -1553,6 +1566,7 @@ fn transitive_peer_merge_preserves_richer_direct_address() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; apply_transitive_ann( &mut existing, diff --git a/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs b/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs index 5f0b79d76e..e01e4a69e9 100644 --- a/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs +++ b/crates/mesh-llm-host-runtime/src/mesh/tests/protocol_frames.rs @@ -323,6 +323,7 @@ async fn transitive_peer_update_refreshes_last_mentioned() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; node.update_transitive_peer(peer_id, &addr, &ann, make_test_endpoint_id(0xee)) diff --git a/crates/mesh-llm-host-runtime/src/protocol/convert.rs b/crates/mesh-llm-host-runtime/src/protocol/convert.rs index 6bcd8e9b35..44ef525d16 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/convert.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/convert.rs @@ -732,6 +732,8 @@ fn proto_cache_affinity_to_local( .then_some(advertisement) } +/// Encodes a local `PeerAnnouncement` to its wire representation, after +/// sanitizing it for outbound gossip. pub(crate) fn local_ann_to_proto_ann( ann: &PeerAnnouncement, ) -> crate::proto::node::PeerAnnouncement { @@ -879,7 +881,51 @@ pub(crate) fn local_ann_to_proto_ann( .cache_affinity .as_ref() .map(local_cache_affinity_to_proto), + claimed_log_head: ann + .claimed_log_head + .as_ref() + .map(local_claimed_log_head_to_proto), + } +} + +/// Converts a local `ClaimedLogHead` to its wire form. No validation here: +/// this node is the one asserting the claim, not receiving it. +fn local_claimed_log_head_to_proto( + head: &crate::mesh::ClaimedLogHead, +) -> crate::proto::node::ClaimedLogHead { + crate::proto::node::ClaimedLogHead { + log_id: head.log_id.clone(), + size: head.size, + root: head.root.clone(), + timestamp_unix_ms: head.timestamp_unix_ms, + claimed_signature: head.claimed_signature.clone(), + signature_algorithm: head.signature_algorithm.clone(), + } +} + +/// Decodes a remote `ClaimedLogHead`, rejecting (as absent — never a panic, +/// never a partial struct) any field that exceeds the memory-safety bounds +/// below. mesh-llm never verifies `claimed_signature`; this only bounds +/// untrusted remote byte lengths, the same ingest hygiene already applied to +/// `CacheAffinityAdvertisement.salt` and remote model names. +fn proto_claimed_log_head_to_local( + head: &crate::proto::node::ClaimedLogHead, +) -> Option { + if head.log_id.len() > MAX_CLAIMED_LOG_ID_BYTES + || head.root.len() > MAX_CLAIMED_LOG_ROOT_BYTES + || head.claimed_signature.len() > MAX_CLAIMED_LOG_SIGNATURE_BYTES + || head.signature_algorithm.len() > MAX_CLAIMED_LOG_SIGNATURE_ALGORITHM_BYTES + { + return None; } + Some(crate::mesh::ClaimedLogHead { + log_id: head.log_id.clone(), + size: head.size, + root: head.root.clone(), + timestamp_unix_ms: head.timestamp_unix_ms, + claimed_signature: head.claimed_signature.clone(), + signature_algorithm: head.signature_algorithm.clone(), + }) } pub(crate) fn build_gossip_frame( @@ -914,6 +960,34 @@ const MAX_REMOTE_MODEL_LIST_LEN: usize = 256; /// under this; anything larger is dropped rather than rendered. const MAX_REMOTE_MODEL_NAME_BYTES: usize = 512; +/// Upper bound on `ClaimedLogHead.log_id`, in bytes. This is a memory-safety +/// limit on untrusted remote bytes, not a format assertion: a log id is not +/// a model name, so it gets its own constant rather than borrowing +/// `MAX_REMOTE_MODEL_NAME_BYTES`, sized to match that existing remote-string +/// cap. +const MAX_CLAIMED_LOG_ID_BYTES: usize = 512; + +/// Upper bound on `ClaimedLogHead.root`, in bytes. This is a memory-safety +/// limit on untrusted remote bytes, not a format assertion: mesh-llm treats +/// the root as opaque and never verifies it, so the bound is sized generously +/// enough to admit hash schemes wider than this node's own SHA-256 (e.g. +/// SHA-512) rather than asserting our own scheme's exact length. +const MAX_CLAIMED_LOG_ROOT_BYTES: usize = 64; + +/// Upper bound on `ClaimedLogHead.claimed_signature`, in bytes. Same +/// memory-safety rationale as `MAX_CLAIMED_LOG_ROOT_BYTES`: wide enough for +/// signature schemes larger than this node's own Ed25519 (e.g. post-quantum +/// signatures), tight enough that a peer cannot advertise unbounded bytes. +const MAX_CLAIMED_LOG_SIGNATURE_BYTES: usize = 128; + +/// Upper bound on `ClaimedLogHead.signature_algorithm`, in bytes. Same +/// memory-safety rationale as the other `ClaimedLogHead` bounds: this is an +/// untrusted remote string, not a known-set validator (mesh-llm never +/// verifies the claim, so it has no fixed list of algorithm names to check +/// against). 32 bytes comfortably fits real scheme identifiers (e.g. +/// `"ed25519"`, `"ml-dsa-65"`) while still capping the field. +const MAX_CLAIMED_LOG_SIGNATURE_ALGORITHM_BYTES: usize = 32; + /// Sanitize a remotely-supplied list of model names: drop entries that exceed /// the per-name byte cap and keep at most `MAX_REMOTE_MODEL_LIST_LEN` of them. fn cap_remote_model_names(names: &[String]) -> Vec { @@ -925,6 +999,10 @@ fn cap_remote_model_names(names: &[String]) -> Vec { .collect() } +/// Decodes a wire `PeerAnnouncement` into its local representation, applying +/// every remote-bytes sanitization boundary (model names, cache affinity, +/// claimed log head) along the way. Returns `None` if the endpoint id itself +/// is malformed. pub(crate) fn proto_ann_to_local( pa: &crate::proto::node::PeerAnnouncement, ) -> Option<(EndpointAddr, PeerAnnouncement)> { @@ -1122,6 +1200,10 @@ pub(crate) fn proto_ann_to_local( .cache_affinity .as_ref() .and_then(proto_cache_affinity_to_local), + claimed_log_head: pa + .claimed_log_head + .as_ref() + .and_then(proto_claimed_log_head_to_local), }; crate::mesh::backfill_legacy_descriptors(&mut ann); ann.advertised_model_throughput = sanitize_model_throughput_hints_for_ann(&ann); diff --git a/crates/mesh-llm-host-runtime/src/protocol/mod.rs b/crates/mesh-llm-host-runtime/src/protocol/mod.rs index ec13eeae33..4a9227d06c 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/mod.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/mod.rs @@ -1,5 +1,7 @@ // Protocol infrastructure — extracted from mesh.rs +#[cfg(test)] +use crate::mesh::ClaimedLogHead; #[cfg(test)] use crate::mesh::NodeRole; use crate::mesh::PeerAnnouncement; diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs index e7921da3f2..fd31ac43c4 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs @@ -1,5 +1,7 @@ use super::*; +/// Proves owner-attestation fields survive a local-to-proto-to-local +/// announcement round trip unchanged. #[test] fn owner_fields_roundtrip_through_proto_announcement() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xAB; 32]).public()); @@ -64,6 +66,7 @@ fn owner_fields_roundtrip_through_proto_announcement() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann); let skippy = proto_pa @@ -134,6 +137,8 @@ pub(crate) fn assert_mixed_version_peer_ignores_missing_release_attestation() { assert!(!peer.release_attestation_summary.verified); } +/// Proves advertised model-throughput hints survive a local-to-proto-to-local +/// announcement round trip unchanged. #[test] fn advertised_model_throughput_roundtrips_through_proto_announcement() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xAC; 32]).public()); @@ -232,6 +237,7 @@ fn advertised_model_throughput_roundtrips_through_proto_announcement() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let mut proto_pa = local_ann_to_proto_ann(&ann); @@ -349,6 +355,8 @@ fn stale_and_far_future_cache_affinity_are_dropped_at_ingest() { assert!(future.cache_affinity.is_none()); } +/// Proves the inference-admission-state field survives a +/// local-to-proto-to-local announcement round trip unchanged. #[test] fn inference_admission_state_roundtrips_through_proto_announcement() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xAD; 32]).public()); @@ -401,6 +409,7 @@ fn inference_admission_state_roundtrips_through_proto_announcement() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: Some(expected_state), + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann); @@ -605,6 +614,8 @@ fn unknown_stage_feature_does_not_enable_local_gguf_content_id_support() { assert!(!ann.local_gguf_content_id_supported); } +/// Proves GPU bandwidth and TFLOPS fields survive a local-to-proto-to-local +/// announcement round trip unchanged. #[test] fn test_proto_round_trip_with_bandwidth_and_tflops() { let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xBC; 32]).public()); @@ -656,6 +667,7 @@ fn test_proto_round_trip_with_bandwidth_and_tflops() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann); @@ -1001,3 +1013,361 @@ fn memory_block_exceeding_the_placement_budget_is_dropped_at_ingest() { "usable equal to the budget is consistent" ); } + +/// Builds a `PeerAnnouncement` fixture for `ClaimedLogHead` conversion +/// tests: every field is empty/default except `claimed_log_head`. +/// Deliberately not built from `mesh::gossip::tests::test_announcement`: +/// that helper lives inside `gossip`'s private `#[cfg(test)] mod tests`, so +/// `mesh::gossip::tests` is unreachable from here (a sibling module tree) +/// regardless of the helper's own `pub(crate)` — verified with `cargo +/// check`, not just asserted. +fn claimed_log_head_test_announcement( + peer_id: EndpointId, + claimed_log_head: Option, +) -> super::super::PeerAnnouncement { + super::super::PeerAnnouncement { + addr: iroh::EndpointAddr { + id: peer_id, + addrs: Default::default(), + }, + role: super::super::NodeRole::Worker, + first_joined_mesh_ts: None, + models: vec![], + vram_bytes: 0, + model_source: None, + serving_models: vec![], + hosted_models: None, + available_models: vec![], + requested_models: vec![], + explicit_model_interests: vec![], + version: None, + model_demand: HashMap::new(), + mesh_id: None, + mesh_policy_hash: None, + gpu_name: None, + hostname: None, + is_soc: None, + gpu_vram: None, + gpu_reserved_bytes: None, + memory: None, + gpu_mem_bandwidth_gbps: None, + gpu_compute_tflops_fp32: None, + gpu_compute_tflops_fp16: None, + available_model_metadata: vec![], + experts_summary: None, + available_model_sizes: HashMap::new(), + served_model_descriptors: vec![], + served_model_runtime: vec![], + owner_attestation: None, + genesis_policy: None, + release_attestation: None, + direct_admission_proof: None, + artifact_transfer_supported: false, + stage_protocol_generation_supported: false, + stage_status_list_supported: false, + local_gguf_content_id_supported: false, + advertised_model_throughput: vec![], + cache_affinity: None, + latency_ms: None, + latency_source: None, + latency_age_ms: None, + latency_observer_id: None, + inference_admission_state: None, + claimed_log_head, + } +} + +/// Builds a wire `PeerAnnouncement` carrying the given proto `ClaimedLogHead`, +/// for tests that need to construct an out-of-bounds field directly on the +/// wire type (the local type has no length limits of its own — the bound is +/// enforced only when decoding untrusted remote bytes). +fn proto_announcement_with_claimed_log_head( + peer_id: EndpointId, + head: crate::proto::node::ClaimedLogHead, +) -> crate::proto::node::PeerAnnouncement { + let base = local_ann_to_proto_ann(&claimed_log_head_test_announcement(peer_id, None)); + crate::proto::node::PeerAnnouncement { + claimed_log_head: Some(head), + ..base + } +} + +/// Proves a `ClaimedLogHead` survives a full wire round trip: local → proto +/// → encoded bytes → decoded proto → local. Exercises tag 51 serialization +/// end-to-end, not just the in-memory conversion functions. +#[test] +fn claimed_log_head_roundtrips_through_proto_announcement() { + use prost::Message; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xCF; 32]).public()); + let expected = super::super::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 42, + root: vec![0xAA; 32], + timestamp_unix_ms: 1_725_000_000_000, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let ann = claimed_log_head_test_announcement(peer_id, Some(expected.clone())); + + let proto_pa = local_ann_to_proto_ann(&ann); + let encoded = proto_pa.encode_to_vec(); + let decoded_proto = crate::proto::node::PeerAnnouncement::decode(encoded.as_slice()) + .expect("claimed log head announcement must decode"); + let proto_head = decoded_proto + .claimed_log_head + .as_ref() + .expect("claimed_log_head must be present on the wire announcement"); + assert_eq!(proto_head.log_id, expected.log_id); + assert_eq!(proto_head.size, expected.size); + assert_eq!(proto_head.root, expected.root); + assert_eq!(proto_head.timestamp_unix_ms, expected.timestamp_unix_ms); + assert_eq!(proto_head.claimed_signature, expected.claimed_signature); + assert_eq!(proto_head.signature_algorithm, expected.signature_algorithm); + + let (_, roundtripped) = + proto_ann_to_local(&decoded_proto).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, Some(expected)); +} + +/// Proves backward compatibility at the wire level: encoding an announcement +/// that has no `claimed_log_head` field and decoding it produces `None` for +/// the field. This is the proto3 optional-field contract. +#[test] +fn proto_announcement_without_claimed_log_head_decodes_as_absent() { + use prost::Message; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD0; 32]).public()); + // Build a local announcement with no claimed_log_head. + let ann = super::super::PeerAnnouncement { + addr: iroh::EndpointAddr { + id: peer_id, + addrs: Default::default(), + }, + role: super::super::NodeRole::Worker, + first_joined_mesh_ts: None, + models: vec![], + vram_bytes: 0, + model_source: None, + serving_models: vec![], + hosted_models: None, + available_models: vec![], + requested_models: vec![], + explicit_model_interests: vec![], + version: None, + model_demand: HashMap::new(), + mesh_id: None, + mesh_policy_hash: None, + gpu_name: Some("NVIDIA A100".to_string()), + hostname: None, + is_soc: None, + gpu_vram: None, + gpu_reserved_bytes: None, + memory: None, + gpu_mem_bandwidth_gbps: None, + gpu_compute_tflops_fp32: None, + gpu_compute_tflops_fp16: None, + available_model_metadata: vec![], + experts_summary: None, + available_model_sizes: HashMap::new(), + served_model_descriptors: vec![], + served_model_runtime: vec![], + owner_attestation: None, + genesis_policy: None, + release_attestation: None, + direct_admission_proof: None, + artifact_transfer_supported: false, + stage_protocol_generation_supported: false, + stage_status_list_supported: false, + local_gguf_content_id_supported: false, + advertised_model_throughput: vec![], + cache_affinity: None, + latency_ms: None, + latency_source: None, + latency_age_ms: None, + latency_observer_id: None, + inference_admission_state: None, + claimed_log_head: None, + }; + // Encode to wire bytes, then decode back — this is what an old peer's + // message looks like on the wire when it has never set tag 51. + let proto_pa = local_ann_to_proto_ann(&ann); + let mut buf = Vec::new(); + proto_pa.encode(&mut buf).expect("encode must succeed"); + let decoded_proto = + crate::proto::node::PeerAnnouncement::decode(buf.as_slice()).expect("decode must succeed"); + let (_, roundtripped) = + proto_ann_to_local(&decoded_proto).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); + assert_eq!(roundtripped.gpu_name.as_deref(), Some("NVIDIA A100")); +} + +/// `ClaimedLogHead.log_id` is bounded by `MAX_CLAIMED_LOG_ID_BYTES` (512): +/// exactly at the bound must still decode as present. +#[test] +fn claimed_log_head_log_id_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD2; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "x".repeat(512), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over `MAX_CLAIMED_LOG_ID_BYTES` must decode as absent — never +/// a panic, never a partial struct. +#[test] +fn claimed_log_head_log_id_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD3; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "x".repeat(513), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// `ClaimedLogHead.root` is bounded at 64 bytes: exactly at the bound must +/// still decode as present. +#[test] +fn claimed_log_head_root_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD4; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 64], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over the 64-byte `root` bound must decode as absent — never a +/// panic, never a partial struct. +#[test] +fn claimed_log_head_root_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD5; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 65], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// `ClaimedLogHead.claimed_signature` is bounded at 128 bytes: exactly at +/// the bound must still decode as present. +#[test] +fn claimed_log_head_claimed_signature_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD6; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 128], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over the 128-byte `claimed_signature` bound must decode as +/// absent — never a panic, never a partial struct. +#[test] +fn claimed_log_head_claimed_signature_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD7; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 129], + signature_algorithm: "ed25519".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// `ClaimedLogHead.signature_algorithm` is bounded at 32 bytes: exactly at +/// the bound must still decode as present. +#[test] +fn claimed_log_head_signature_algorithm_at_limit_decodes_as_present() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD8; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "x".repeat(32), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert!(roundtripped.claimed_log_head.is_some()); +} + +/// One byte over the 32-byte `signature_algorithm` bound must decode as +/// absent — never a panic, never a partial struct. +#[test] +fn claimed_log_head_signature_algorithm_over_limit_decodes_as_absent() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xD9; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: "x".repeat(33), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!(roundtripped.claimed_log_head, None); +} + +/// proto3 defaults `signature_algorithm` to `""`; an empty value must +/// round-trip as empty and never be silently defaulted (e.g. to +/// `"ed25519"`) anywhere in the conversion path. An empty `signature_algorithm` +/// means the peer named no scheme — this is `ClaimedLogHead`'s field-level +/// analogue of the house rule already enforced on +/// `SignedMeshGenesisPolicy`/`SignedBootstrapToken`/`DirectNodeAdmissionProof` +/// (`validate_unsigned_shape` / `validate_shape` in +/// `mesh-llm-host-runtime/src/mesh/requirements.rs`), which reject an empty +/// `signature_algorithm` rather than default it. +#[test] +fn claimed_log_head_empty_signature_algorithm_roundtrips_as_empty() { + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xDA; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; 64], + signature_algorithm: String::new(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + let roundtripped_head = roundtripped + .claimed_log_head + .expect("empty signature_algorithm must not cause the whole head to be dropped"); + assert_eq!(roundtripped_head.signature_algorithm, ""); +} diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs index 91b0ddadc6..8dad165a61 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/mesh_timestamps.rs @@ -1,5 +1,7 @@ use super::*; +/// Proves `first_joined_mesh_ts` survives a local-to-proto-to-local +/// announcement round trip unchanged, both present and absent. #[test] fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { use iroh::SecretKey; @@ -55,6 +57,7 @@ fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann_with_timestamp); @@ -114,6 +117,7 @@ fn test_peer_announcement_first_joined_mesh_ts_roundtrip() { latency_age_ms: None, latency_observer_id: None, inference_admission_state: None, + claimed_log_head: None, }; let proto_pa = local_ann_to_proto_ann(&ann_without_timestamp); diff --git a/crates/mesh-llm-protocol/proto/node.proto b/crates/mesh-llm-protocol/proto/node.proto index d15c77e653..91c34a74ab 100644 --- a/crates/mesh-llm-protocol/proto/node.proto +++ b/crates/mesh-llm-protocol/proto/node.proto @@ -61,6 +61,80 @@ message PeerAnnouncement { optional InferenceAdmissionState inference_admission_state = 49; // Positive, short-lived cache evidence. Digests are salted and contain no tokens. optional CacheAffinityAdvertisement cache_affinity = 50; + // An optional, self-reported claim this node MAY advertise about the head + // of its own append-only history. Advisory only. Absence has four + // possible causes, and a receiver cannot tell which one applies from this + // field alone: (1) the peer does not advertise one; (2) the peer has not + // produced one yet; (3) the head originated more than one hop away — this + // implementation does not relay `claimed_log_head` transitively (see + // `apply_transitive_ann`), so a receiver only ever sees heads from + // directly-connected peers; (4) a field exceeded its bound and the whole + // head was dropped on decode (see `proto_claimed_log_head_to_local`). + // Never verified by mesh-llm itself — carried opaquely so a receiver MAY + // verify independently. + optional ClaimedLogHead claimed_log_head = 51; +} + +// A minimal, self-contained claim about the current head of a peer's +// append-only log. `claimed_signature` is claimed by the announcing peer to +// be a signature, using the scheme named in `signature_algorithm`, by the +// peer's own node key (the same key backing its `endpoint_id`) over the +// following byte string ("sig_input"), so a third party can implement a +// verifier without consulting any external document. `sig_input` is +// constructed the same way as `SignedMeshGenesisPolicy`'s canonical bytes, +// using this crate's `write_string`/`write_bytes` conventions (see +// `mesh-llm-host-runtime/src/mesh/requirements.rs`): +// +// sig_input = b"mesh-llm-claimed-log-head-v1:" +// || u64le(len(log_id)) || log_id +// || u64le(size) +// || u64le(len(root)) || root +// || u64le(timestamp_unix_ms) +// || u64le(len(signature_algorithm)) || signature_algorithm +// +// All integers are little-endian, fixed 8 bytes (u64le) — matching this +// crate's existing canonical-bytes convention, the same one used for +// `SignedMeshGenesisPolicy`, `SignedBootstrapToken`, and +// `DirectNodeAdmissionProof`. `len(log_id)` and `len(signature_algorithm)` +// are each the length in bytes of the UTF-8 encoding of the string, not a +// character count. Every variable-length field is length-prefixed, so +// concatenation cannot be ambiguous between adjacent fields. The leading +// domain-separation tag stops a signature produced for another protocol +// from being replayed here. +// `signature_algorithm` is itself bound into `sig_input` — otherwise an +// attacker could strip or swap it on the wire, and algorithm agility would +// become an attack surface instead of a feature. The value bound in is +// `signature_algorithm.trim()`, the same trimmed value a verifier must +// compare against, so two peers that differ only in surrounding whitespace +// sign and verify identical bytes. +// `signature_algorithm` names the scheme with a lowercase string, e.g. +// `"ed25519"` (matching `ED25519_SIGNATURE_ALGORITHM` in +// `mesh-llm-host-runtime/src/mesh/requirements.rs`). Comparison is +// `.trim()` then exact `==` — whitespace-tolerant, case-sensitive — the +// same convention already used to check this field on +// `SignedMeshGenesisPolicy`/`SignedBootstrapToken`/ +// `DirectNodeAdmissionProof`. An absent or empty `signature_algorithm` +// means the peer named no scheme: a verifier cannot check the claim and +// MUST NOT assume one (in particular, MUST NOT default to Ed25519) — empty +// is never defaulted, the same house rule those three messages already +// enforce. +// mesh-llm carries this opaquely and never verifies it itself — the name +// reflects that nothing here has checked the claim. A consumer that does +// verify it may define its own `VerifiedLogHead` type; none exists in this +// crate. +// `log_id`, `root`, `claimed_signature`, and `signature_algorithm` are +// bounded at the conversion boundary (see +// `mesh-llm-host-runtime/src/protocol/convert.rs`) as a memory-safety limit +// on untrusted remote bytes, not a format assertion — the bounds are wide +// enough for hash/signature schemes other than the SHA-256/Ed25519 this +// node itself uses. +message ClaimedLogHead { + string log_id = 1; + uint64 size = 2; + bytes root = 3; + uint64 timestamp_unix_ms = 4; + bytes claimed_signature = 5; + string signature_algorithm = 6; } enum InferenceAdmissionState { diff --git a/crates/mesh-llm-protocol/src/proto/node.rs b/crates/mesh-llm-protocol/src/proto/node.rs index 36b009cfeb..d9de646cd5 100644 --- a/crates/mesh-llm-protocol/src/proto/node.rs +++ b/crates/mesh-llm-protocol/src/proto/node.rs @@ -132,6 +132,87 @@ pub struct PeerAnnouncement { /// Positive, short-lived cache evidence. Digests are salted and contain no tokens. #[prost(message, optional, tag = "50")] pub cache_affinity: ::core::option::Option, + /// An optional, self-reported claim this node MAY advertise about the head + /// of its own append-only history. Advisory only. Absence has four + /// possible causes, and a receiver cannot tell which one applies from this + /// field alone: (1) the peer does not advertise one; (2) the peer has not + /// produced one yet; (3) the head originated more than one hop away — this + /// implementation does not relay `claimed_log_head` transitively (see + /// `apply_transitive_ann`), so a receiver only ever sees heads from + /// directly-connected peers; (4) a field exceeded its bound and the whole + /// head was dropped on decode (see `proto_claimed_log_head_to_local`). + /// Never verified by mesh-llm itself — carried opaquely so a receiver MAY + /// verify independently. + #[prost(message, optional, tag = "51")] + pub claimed_log_head: ::core::option::Option, +} +/// A minimal, self-contained claim about the current head of a peer's +/// append-only log. `claimed_signature` is claimed by the announcing peer to +/// be a signature, using the scheme named in `signature_algorithm`, by the +/// peer's own node key (the same key backing its `endpoint_id`) over the +/// following byte string ("sig_input"), so a third party can implement a +/// verifier without consulting any external document. `sig_input` is +/// constructed the same way as `SignedMeshGenesisPolicy`'s canonical bytes, +/// using this crate's `write_string`/`write_bytes` conventions (see +/// `mesh-llm-host-runtime/src/mesh/requirements.rs`): +/// +/// sig_input = b"mesh-llm-claimed-log-head-v1:" +/// || u64le(len(log_id)) || log_id +/// || u64le(size) +/// || u64le(len(root)) || root +/// || u64le(timestamp_unix_ms) +/// || u64le(len(signature_algorithm)) || signature_algorithm +/// +/// All integers are little-endian, fixed 8 bytes (u64le) — matching this +/// crate's existing canonical-bytes convention, the same one used for +/// `SignedMeshGenesisPolicy`, `SignedBootstrapToken`, and +/// `DirectNodeAdmissionProof`. `len(log_id)` and `len(signature_algorithm)` +/// are each the length in bytes of the UTF-8 encoding of the string, not a +/// character count. Every variable-length field is length-prefixed, so +/// concatenation cannot be ambiguous between adjacent fields. The leading +/// domain-separation tag stops a signature produced for another protocol +/// from being replayed here. +/// `signature_algorithm` is itself bound into `sig_input` — otherwise an +/// attacker could strip or swap it on the wire, and algorithm agility would +/// become an attack surface instead of a feature. The value bound in is +/// `signature_algorithm.trim()`, the same trimmed value a verifier must +/// compare against, so two peers that differ only in surrounding whitespace +/// sign and verify identical bytes. +/// `signature_algorithm` names the scheme with a lowercase string, e.g. +/// `"ed25519"` (matching `ED25519_SIGNATURE_ALGORITHM` in +/// `mesh-llm-host-runtime/src/mesh/requirements.rs`). Comparison is +/// `.trim()` then exact `==` — whitespace-tolerant, case-sensitive — the +/// same convention already used to check this field on +/// `SignedMeshGenesisPolicy`/`SignedBootstrapToken`/ +/// `DirectNodeAdmissionProof`. An absent or empty `signature_algorithm` +/// means the peer named no scheme: a verifier cannot check the claim and +/// MUST NOT assume one (in particular, MUST NOT default to Ed25519) — empty +/// is never defaulted, the same house rule those three messages already +/// enforce. +/// mesh-llm carries this opaquely and never verifies it itself — the name +/// reflects that nothing here has checked the claim. A consumer that does +/// verify it may define its own `VerifiedLogHead` type; none exists in this +/// crate. +/// `log_id`, `root`, `claimed_signature`, and `signature_algorithm` are +/// bounded at the conversion boundary (see +/// `mesh-llm-host-runtime/src/protocol/convert.rs`) as a memory-safety limit +/// on untrusted remote bytes, not a format assertion — the bounds are wide +/// enough for hash/signature schemes other than the SHA-256/Ed25519 this +/// node itself uses. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ClaimedLogHead { + #[prost(string, tag = "1")] + pub log_id: ::prost::alloc::string::String, + #[prost(uint64, tag = "2")] + pub size: u64, + #[prost(bytes = "vec", tag = "3")] + pub root: ::prost::alloc::vec::Vec, + #[prost(uint64, tag = "4")] + pub timestamp_unix_ms: u64, + #[prost(bytes = "vec", tag = "5")] + pub claimed_signature: ::prost::alloc::vec::Vec, + #[prost(string, tag = "6")] + pub signature_algorithm: ::prost::alloc::string::String, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct AdvertisedModelThroughput { From 25d68b048b06835b1c5aa787f3a944a89115a350 Mon Sep 17 00:00:00 2001 From: stevenmih Date: Fri, 11 Sep 2026 16:37:39 -0700 Subject: [PATCH 2/2] fix(mesh): stop claiming MAX_CLAIMED_LOG_SIGNATURE_BYTES fits post-quantum sigs The 128-byte bound's own doc comment claimed it was "wide enough for signature schemes larger than this node's own Ed25519 (e.g. post-quantum signatures)". It is not: ML-DSA-65 signatures are 3,309 bytes, and NIST FIPS 204 gives the ML-DSA range as 2,420-4,627 bytes -- 128 bytes admits none of them. CodeRabbit flagged this on PR #1709 (convert.rs:937); it conflated this constant with the adjacent MAX_CLAIMED_LOG_SIGNATURE_ALGORITHM_BYTES (32 bytes, correctly documented via the "ml-dsa-65" algorithm-identifier-string example), but the underlying point about THIS constant's comment was right. Rewrite the comment to state plainly what 128 bytes is for (Ed25519 and classical schemes of similar size) and why it stays that size rather than being widened for PQ: claimed_log_head rides on PeerAnnouncement, which is gossiped to every peer, and this node never verifies claimed_signature -- so admitting real PQ-sized signatures would put multi-kilobyte unverified blobs on a hot broadcast path. Accepting PQ claims would need a deliberate bound raise with its own rationale, not a default this constant already provides. Add a boundary test asserting a real ML-DSA-65-sized (3,309-byte) claimed signature decodes as absent, the same as any other oversized claim -- directly falsifying the old comment's claim and pinning down the new one. Adversarial pass: with the bound temporarily raised to 4,627 bytes (the behavior the old comment implied), this test fails; restored to 128, it passes alongside the rest of the suite (30/30 in protocol::tests::announcements). Signed-off-by: stevenmih --- .../src/protocol/convert.rs | 16 ++++++++--- .../src/protocol/tests/announcements.rs | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/mesh-llm-host-runtime/src/protocol/convert.rs b/crates/mesh-llm-host-runtime/src/protocol/convert.rs index 44ef525d16..8e72d48c8d 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/convert.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/convert.rs @@ -974,10 +974,18 @@ const MAX_CLAIMED_LOG_ID_BYTES: usize = 512; /// SHA-512) rather than asserting our own scheme's exact length. const MAX_CLAIMED_LOG_ROOT_BYTES: usize = 64; -/// Upper bound on `ClaimedLogHead.claimed_signature`, in bytes. Same -/// memory-safety rationale as `MAX_CLAIMED_LOG_ROOT_BYTES`: wide enough for -/// signature schemes larger than this node's own Ed25519 (e.g. post-quantum -/// signatures), tight enough that a peer cannot advertise unbounded bytes. +/// Upper bound on `ClaimedLogHead.claimed_signature`, in bytes. Sized for +/// this node's own Ed25519 (64 bytes) and classical schemes of similar +/// size, with headroom for encoding overhead — **not** for post-quantum +/// schemes: ML-DSA-65 signatures are 3,309 bytes (NIST FIPS 204 gives the +/// ML-DSA range as 2,420-4,627 bytes), all of which this bound rejects. +/// That is deliberate, not an oversight: `claimed_log_head` rides on +/// `PeerAnnouncement`, which is gossiped to every peer, and this node never +/// verifies `claimed_signature` (see `proto_claimed_log_head_to_local` +/// above) — so a bound wide enough to admit a real PQ signature would put +/// multi-kilobyte unverified blobs on a hot broadcast path. Accepting PQ +/// claims would need a deliberate bound raise with its own rationale, not +/// a default this constant already provides. const MAX_CLAIMED_LOG_SIGNATURE_BYTES: usize = 128; /// Upper bound on `ClaimedLogHead.signature_algorithm`, in bytes. Same diff --git a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs index fd31ac43c4..19b2750131 100644 --- a/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs +++ b/crates/mesh-llm-host-runtime/src/protocol/tests/announcements.rs @@ -1308,6 +1308,33 @@ fn claimed_log_head_claimed_signature_over_limit_decodes_as_absent() { assert_eq!(roundtripped.claimed_log_head, None); } +/// `MAX_CLAIMED_LOG_SIGNATURE_BYTES` (128) does **not** admit a real +/// post-quantum signature: an ML-DSA-65 signature (3,309 bytes, NIST FIPS +/// 204) must decode as absent, the same as any other oversized claim. This +/// pins down the corrected rationale on the constant's doc comment — the +/// bound is sized for Ed25519/classical schemes only, not "wide enough for +/// post-quantum signatures" as an earlier (incorrect) comment claimed. +#[test] +fn claimed_log_head_claimed_signature_rejects_ml_dsa_65_size() { + const ML_DSA_65_SIGNATURE_BYTES: usize = 3_309; + let peer_id = EndpointId::from(SecretKey::from_bytes(&[0xDB; 32]).public()); + let head = crate::proto::node::ClaimedLogHead { + log_id: "log-abc".to_string(), + size: 1, + root: vec![0xAA; 32], + timestamp_unix_ms: 1, + claimed_signature: vec![0xBB; ML_DSA_65_SIGNATURE_BYTES], + signature_algorithm: "ml-dsa-65".to_string(), + }; + let proto_pa = proto_announcement_with_claimed_log_head(peer_id, head); + let (_, roundtripped) = proto_ann_to_local(&proto_pa).expect("proto_ann_to_local must succeed"); + assert_eq!( + roundtripped.claimed_log_head, None, + "an ML-DSA-65-sized signature must decode as absent: the 128-byte \ + bound does not admit post-quantum schemes" + ); +} + /// `ClaimedLogHead.signature_algorithm` is bounded at 32 bytes: exactly at /// the bound must still decode as present. #[test]