A25: surface a clock-skew warning instead of failing silently
A validly-signed gossip payload rejected only by the 120s replay freshness window (GossipReject::OutOfWindow) now drives a room-level "clocks out of sync" warning banner, instead of silently dropping the peer so the room shows "1 in room" with no error. Observe-only: verify_gossip's accept/reject decision and GOSSIP_FRESHNESS_MS are unchanged; the payload is still dropped exactly as before. The warning is gated strictly on OutOfWindow (which, because the signature is verified first, implies a genuine authenticated peer whose clock is skewed), never on BadSignature. Policy lives in a pure, unit-tested ClockSkewMonitor seam with injected now_ms: >=3 OutOfWindow drops from the same author within 60s warn once, 5-min per-author cooldown, bounded/pruned author map. The warning rides the existing in-process RoomEvent -> UiEvent -> transient-banner path (no wire/serialization or dependency change). Implemented by Codex (gpt-5.5), senior-audited against the 5-point checklist and independently verified (452 lib tests, clippy --all-targets clean, release build green). Tests-green only; a 2-machine deliberate-skew field test is still owed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+173
-6
@@ -262,6 +262,8 @@ const ABOVE_CHAT_MIN_H: f32 = 300.0;
|
||||
const DIVIDER_THICKNESS: f32 = 8.0;
|
||||
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
|
||||
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
|
||||
/// How long a room-level clock-skew warning remains visible without dismissal.
|
||||
const CLOCK_SKEW_WARNING_VISIBLE_SECS: u64 = 12;
|
||||
|
||||
/// Clamp the Participants panel width so neither it nor the Controls panel drops
|
||||
/// below its minimum, given the current window width.
|
||||
@@ -404,6 +406,10 @@ pub enum AppMessage {
|
||||
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
|
||||
OpenPixelpassHelp,
|
||||
ClosePixelpassHelp,
|
||||
/// Dismiss the room-level clock-skew warning banner.
|
||||
DismissClockSkewWarning,
|
||||
/// Auto-clear cadence while the clock-skew warning banner is visible.
|
||||
ClockSkewWarningTick,
|
||||
/// Choose a room layout (applied live + persisted, closes the popup).
|
||||
SelectRoomLayout(RoomLayout),
|
||||
/// Choose a UI theme (applied live + persisted).
|
||||
@@ -499,6 +505,13 @@ fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ClockSkewBanner {
|
||||
skew_secs: u64,
|
||||
peer_ahead: bool,
|
||||
expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
name: String,
|
||||
ticket_input: String,
|
||||
@@ -604,6 +617,10 @@ pub struct AppState {
|
||||
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
|
||||
/// until the core's `AudioAppsListed` reports otherwise.
|
||||
share_app_audio_supported: bool,
|
||||
/// Room-level warning for a validly signed peer whose gossip timestamp falls
|
||||
/// outside the replay freshness window. The peer is not yet in the roster, so
|
||||
/// this is not attached to a participant card.
|
||||
clock_skew_warning: Option<ClockSkewBanner>,
|
||||
/// Whether the Chat drawer is open (drawer layout only).
|
||||
drawer_chat_open: bool,
|
||||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||
@@ -680,6 +697,7 @@ impl AppState {
|
||||
self.share_audio_dropped = false;
|
||||
self.share_audio_app_active = false;
|
||||
self.share_app_audio_supported = true;
|
||||
self.clock_skew_warning = None;
|
||||
}
|
||||
|
||||
fn custom_sound_path(&self, sound: Sound) -> &str {
|
||||
@@ -808,6 +826,7 @@ impl Default for AppState {
|
||||
share_audio_dropped: false,
|
||||
share_audio_app_active: false,
|
||||
share_app_audio_supported: true,
|
||||
clock_skew_warning: None,
|
||||
drawer_chat_open: false,
|
||||
mic_level: 0.0,
|
||||
mic_test_active: false,
|
||||
@@ -952,7 +971,13 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
Subscription::batch(vec![core_sub, event_sub, audio_sub])
|
||||
let clock_skew_sub = if state.clock_skew_warning.is_some() {
|
||||
iced::time::every(std::time::Duration::from_secs(1))
|
||||
.map(|_| AppMessage::ClockSkewWarningTick)
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub])
|
||||
}
|
||||
|
||||
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||||
@@ -1429,6 +1454,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.share_audio_dropped = !active;
|
||||
}
|
||||
}
|
||||
UiEvent::ClockSkewWarning { skew_secs, peer_ahead } => {
|
||||
show_clock_skew_warning(
|
||||
state,
|
||||
skew_secs,
|
||||
peer_ahead,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
UiEvent::IdentityStatus { node_id, persisted, error } => {
|
||||
state.self_node_id = Some(node_id);
|
||||
state.identity_persisted = persisted;
|
||||
@@ -1793,6 +1826,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
AppMessage::ClosePixelpassHelp => {
|
||||
state.pixelpass_help_open = false;
|
||||
}
|
||||
AppMessage::DismissClockSkewWarning => {
|
||||
state.clock_skew_warning = None;
|
||||
}
|
||||
AppMessage::ClockSkewWarningTick => {
|
||||
clear_expired_clock_skew_warning(state, std::time::Instant::now());
|
||||
}
|
||||
AppMessage::SelectRoomLayout(layout) => {
|
||||
state.config.room_layout = layout;
|
||||
state.config.save();
|
||||
@@ -2363,6 +2402,37 @@ fn format_duration(total_secs: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_clock_skew_duration(skew_secs: u64) -> String {
|
||||
let minutes = skew_secs.max(1).saturating_add(59) / 60;
|
||||
if minutes == 1 {
|
||||
"1 minute".to_string()
|
||||
} else {
|
||||
format!("{minutes} minutes")
|
||||
}
|
||||
}
|
||||
|
||||
fn show_clock_skew_warning(
|
||||
state: &mut AppState,
|
||||
skew_secs: u64,
|
||||
peer_ahead: bool,
|
||||
now: std::time::Instant,
|
||||
) {
|
||||
state.clock_skew_warning = Some(ClockSkewBanner {
|
||||
skew_secs,
|
||||
peer_ahead,
|
||||
expires_at: now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
|
||||
});
|
||||
}
|
||||
|
||||
fn clear_expired_clock_skew_warning(state: &mut AppState, now: std::time::Instant) {
|
||||
if state
|
||||
.clock_skew_warning
|
||||
.is_some_and(|warning| now >= warning.expires_at)
|
||||
{
|
||||
state.clock_skew_warning = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// First 8 characters of an id string for compact display. Panic-free: takes
|
||||
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
|
||||
/// render (security finding S1) — ids are long ASCII hex today, but this guards
|
||||
@@ -4887,8 +4957,43 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
}
|
||||
};
|
||||
|
||||
let clock_skew_banner: Element<'_, AppMessage> =
|
||||
if let Some(warning) = state.clock_skew_warning {
|
||||
let direction = if warning.peer_ahead { "ahead" } else { "behind" };
|
||||
let skew = format_clock_skew_duration(warning.skew_secs);
|
||||
let copy = format!(
|
||||
"A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)."
|
||||
);
|
||||
column![
|
||||
vertical_space(10.0),
|
||||
container(
|
||||
row![
|
||||
icon(IconKind::Clock, 16.0, color_yellow),
|
||||
text(copy).size(12).color(color_text).width(iced::Length::Fill),
|
||||
button(text("Dismiss").size(12))
|
||||
.on_press(AppMessage::DismissClockSkewWarning)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(6),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::alignment::Vertical::Center)
|
||||
)
|
||||
.padding(10)
|
||||
.width(iced::Length::Fill)
|
||||
.style(move |_theme: &Theme| container::Style {
|
||||
text_color: Some(color_text),
|
||||
background: Some(Background::Color(Color { a: 0.14, ..color_yellow })),
|
||||
border: Border { color: color_yellow, width: 1.0, radius: 8.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
};
|
||||
|
||||
let room = container(
|
||||
column![top_bar, header_container, vertical_space(12.0), body]
|
||||
column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body]
|
||||
)
|
||||
.padding(15)
|
||||
.width(iced::Length::Fill)
|
||||
@@ -6222,10 +6327,11 @@ impl Program<AppMessage> for Icon {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime,
|
||||
reconnected_chime, set_peer_gate_config, set_peer_volume_config, update, AppConfig,
|
||||
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
|
||||
UiEvent,
|
||||
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
|
||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
|
||||
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
|
||||
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
|
||||
};
|
||||
use iroh::SecretKey;
|
||||
|
||||
@@ -6392,6 +6498,11 @@ mod tests {
|
||||
state.share_audio_dropped = true;
|
||||
state.share_audio_app_active = true;
|
||||
state.share_app_audio_supported = false;
|
||||
state.clock_skew_warning = Some(ClockSkewBanner {
|
||||
skew_secs: 180,
|
||||
peer_ahead: true,
|
||||
expires_at: now,
|
||||
});
|
||||
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
|
||||
|
||||
state.reset_room_state();
|
||||
@@ -6419,6 +6530,7 @@ mod tests {
|
||||
assert!(!state.share_audio_dropped);
|
||||
assert!(!state.share_audio_app_active);
|
||||
assert!(state.share_app_audio_supported, "reset is optimistic by default");
|
||||
assert!(state.clock_skew_warning.is_none());
|
||||
|
||||
for _ in 0..50 {
|
||||
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
|
||||
@@ -6429,6 +6541,61 @@ mod tests {
|
||||
panic!("clip player did not stop during room reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_warning_shows_dismisses_and_expires() {
|
||||
let mut state = AppState::default();
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
show_clock_skew_warning(&mut state, 181, true, now);
|
||||
let warning = state.clock_skew_warning.expect("warning should be visible");
|
||||
assert_eq!(warning.skew_secs, 181);
|
||||
assert!(warning.peer_ahead);
|
||||
assert_eq!(
|
||||
warning.expires_at,
|
||||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS)
|
||||
);
|
||||
|
||||
let _ = update(&mut state, AppMessage::DismissClockSkewWarning);
|
||||
assert!(state.clock_skew_warning.is_none());
|
||||
|
||||
show_clock_skew_warning(&mut state, 240, false, now);
|
||||
clear_expired_clock_skew_warning(
|
||||
&mut state,
|
||||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS - 1),
|
||||
);
|
||||
assert!(state.clock_skew_warning.is_some());
|
||||
clear_expired_clock_skew_warning(
|
||||
&mut state,
|
||||
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
|
||||
);
|
||||
assert!(state.clock_skew_warning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_ui_event_populates_banner() {
|
||||
let mut state = AppState::default();
|
||||
let _ = update(
|
||||
&mut state,
|
||||
AppMessage::UiEventReceived(UiEvent::ClockSkewWarning {
|
||||
skew_secs: 121,
|
||||
peer_ahead: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let warning = state.clock_skew_warning.expect("event should show banner");
|
||||
assert_eq!(warning.skew_secs, 121);
|
||||
assert!(!warning.peer_ahead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_skew_duration_rounds_up_to_minutes() {
|
||||
assert_eq!(format_clock_skew_duration(0), "1 minute");
|
||||
assert_eq!(format_clock_skew_duration(1), "1 minute");
|
||||
assert_eq!(format_clock_skew_duration(60), "1 minute");
|
||||
assert_eq!(format_clock_skew_duration(61), "2 minutes");
|
||||
assert_eq!(format_clock_skew_duration(181), "4 minutes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_picker_startup_window_is_guarded() {
|
||||
// P3-1: between confirming the picker and the core's ScreenShareStarted,
|
||||
|
||||
Reference in New Issue
Block a user