Skip to content

Commit f643208

Browse files
sanityclaude
andauthored
fix(ui): detect awaiting sync for owner imports via config signature (#200)
* fix(ui): detect awaiting sync for owner imports via config signature The previous is_awaiting_initial_sync() had an owner bypass that returned false immediately for room owners. This caused owner identity imports to skip the GET-first sync flow, PUT the default state (unsigned config) directly to the contract, and allow message sends before sync completed. Every UPDATE then failed with "State verification failed: Invalid signature: signature error" because the default AuthorizedConfigurationV1 is signed by SigningKey([0; 32]), not the real owner. Fix: check whether the configuration signature verifies against the owner's key instead of using ownership as a proxy for "already synced". This correctly detects the placeholder state for both owner and non-owner imports, while newly-created rooms pass (their config is signed by the real owner at creation time). Updated the test to cover the owner-import case (the exact bug) using AuthorizedConfigurationV1::default() for imported rooms instead of a properly-signed config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update stale comment in get_response.rs The comment referenced the old is_awaiting_initial_sync logic ("returns false once members are populated"). Updated to reflect the new signature-based check. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a8b5744 commit f643208

2 files changed

Lines changed: 32 additions & 50 deletions

File tree

ui/src/components/app/freenet_api/response_handler/get_response.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,7 @@ pub async fn handle_get_response(
620620
// Reset to Disconnected so the retry loop can pick it up.
621621
// After GET+merge the state is valid, so the next attempt
622622
// will take the normal PUT path (is_awaiting_initial_sync
623-
// returns false once members are populated).
623+
// returns false once config has a valid owner signature).
624624
crate::util::defer(move || {
625625
SYNC_INFO.with_mut(|sync_info| {
626626
sync_info.update_sync_status(

ui/src/room_data.rs

Lines changed: 31 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -100,21 +100,19 @@ impl RoomData {
100100

101101
/// Check if the room state has been populated from the network.
102102
/// A room that was just imported (or created but not yet synced) will have
103-
/// an empty members list AND empty messages. This is used to show a
104-
/// "Syncing..." indicator and disable message input until the real room
105-
/// state arrives from the network.
103+
/// a default configuration signed by a zero key, not the real owner.
104+
/// This is used to show a "Syncing..." indicator and disable message input
105+
/// until the real room state arrives from the network.
106106
///
107-
/// Uses both empty members AND empty messages to distinguish "just imported"
108-
/// from "synced but all members pruned for inactivity" — in the latter case,
109-
/// messages would still be present and the user should see the normal
110-
/// can_participate() flow which handles re-adding via self_authorized_member.
107+
/// Checks that the configuration signature verifies against the owner's key.
108+
/// The default AuthorizedConfigurationV1 is signed by SigningKey([0; 32]),
109+
/// which will fail verification against any real owner key. This works for
110+
/// both owner and non-owner imports.
111111
pub fn is_awaiting_initial_sync(&self) -> bool {
112-
let is_owner = self.self_sk.verifying_key() == self.owner_vk;
113-
if is_owner {
114-
return false;
115-
}
116-
self.room_state.members.members.is_empty()
117-
&& self.room_state.recent_messages.messages.is_empty()
112+
self.room_state
113+
.configuration
114+
.verify_signature(&self.owner_vk)
115+
.is_err()
118116
}
119117

120118
/// Check if the room is in private mode
@@ -1020,13 +1018,18 @@ mod tests {
10201018
let contract_key =
10211019
ContractKey::from_params_and_code(Parameters::from(params_bytes), &contract_code);
10221020

1023-
let make_room = |sk: SigningKey, members: Vec<AuthorizedMember>| {
1024-
let config = AuthorizedConfigurationV1::new(Configuration::default(), &owner_sk);
1025-
let mut room_state = ChatRoomStateV1 {
1021+
// use_default_config=true simulates an imported room (config signed by zero key),
1022+
// use_default_config=false simulates a created or synced room (config signed by owner).
1023+
let make_room = |sk: SigningKey, use_default_config: bool| {
1024+
let config = if use_default_config {
1025+
AuthorizedConfigurationV1::default()
1026+
} else {
1027+
AuthorizedConfigurationV1::new(Configuration::default(), &owner_sk)
1028+
};
1029+
let room_state = ChatRoomStateV1 {
10261030
configuration: config,
10271031
..Default::default()
10281032
};
1029-
room_state.members.members = members;
10301033
RoomData {
10311034
owner_vk,
10321035
room_state,
@@ -1044,43 +1047,22 @@ mod tests {
10441047
}
10451048
};
10461049

1047-
// Owner with empty members: NOT awaiting sync (owner created the room)
1048-
let owner_room = make_room(owner_sk.clone(), vec![]);
1050+
// Owner-created room (config signed by owner): NOT awaiting sync
1051+
let owner_room = make_room(owner_sk.clone(), false);
10491052
assert!(!owner_room.is_awaiting_initial_sync());
10501053

1051-
// Imported room with empty members: IS awaiting sync
1052-
let imported_room = make_room(invitee_sk.clone(), vec![]);
1054+
// Owner import with default state (the bug case): IS awaiting sync
1055+
// Previously this returned false due to owner bypass, causing signature failures
1056+
let owner_imported = make_room(owner_sk.clone(), true);
1057+
assert!(owner_imported.is_awaiting_initial_sync());
1058+
1059+
// Non-owner import with default state: IS awaiting sync
1060+
let imported_room = make_room(invitee_sk.clone(), true);
10531061
assert!(imported_room.is_awaiting_initial_sync());
10541062

1055-
// Imported room after sync (members populated): NOT awaiting sync
1056-
let member = Member {
1057-
owner_member_id: owner_vk.into(),
1058-
invited_by: owner_vk.into(),
1059-
member_vk: invitee_sk.verifying_key(),
1060-
};
1061-
let auth_member = AuthorizedMember::new(member, &owner_sk);
1062-
let synced_room = make_room(invitee_sk.clone(), vec![auth_member]);
1063+
// Non-owner synced room (config signed by owner): NOT awaiting sync
1064+
let synced_room = make_room(invitee_sk.clone(), false);
10631065
assert!(!synced_room.is_awaiting_initial_sync());
1064-
1065-
// Synced room with members pruned but messages present: NOT awaiting sync
1066-
// (user can re-add themselves via self_authorized_member in can_participate)
1067-
let mut pruned_room = make_room(invitee_sk, vec![]);
1068-
use river_core::room_state::message::{AuthorizedMessageV1, MessageV1, RoomMessageBody};
1069-
let dummy_msg = AuthorizedMessageV1 {
1070-
message: MessageV1 {
1071-
room_owner: owner_vk.into(),
1072-
author: owner_vk.into(),
1073-
content: RoomMessageBody::public("test".to_string()),
1074-
time: std::time::SystemTime::UNIX_EPOCH,
1075-
},
1076-
signature: ed25519_dalek::Signature::from_bytes(&[0u8; 64]),
1077-
};
1078-
pruned_room
1079-
.room_state
1080-
.recent_messages
1081-
.messages
1082-
.push(dummy_msg);
1083-
assert!(!pruned_room.is_awaiting_initial_sync());
10841066
}
10851067

10861068
/// Helper to build a RoomData for rejoin tests.

0 commit comments

Comments
 (0)