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
61 changes: 52 additions & 9 deletions cli/src/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use anyhow::{anyhow, Result};
use clap::Subcommand;
use ed25519_dalek::VerifyingKey;
use river_core::room_state::identity::IdentityExport;
use river_core::room_state::member::MemberId;
use river_core::room_state::member::{AuthorizedMember, Member, MemberId};
use river_core::room_state::ChatRoomParametersV1;

#[derive(Subcommand)]
pub enum IdentityCommands {
Expand Down Expand Up @@ -61,13 +62,55 @@ async fn export_identity(
.get(&key_str)
.ok_or_else(|| anyhow!("Room data not found in storage"))?;

let authorized_member = room_info.self_authorized_member.clone().ok_or_else(|| {
anyhow!(
"No authorized member data found. This can happen if you created this room \
before the membership tracking feature was added. Try sending a message first \
to populate the membership data."
)
})?;
let is_owner = signing_key.verifying_key() == room_owner_key;

// Resolve AuthorizedMember and invite chain:
// 1. Use cached self_authorized_member if available
// 2. For owners: create a self-signed AuthorizedMember
// 3. For non-owners: look up from network state
let (authorized_member, invite_chain) =
if let Some(am) = room_info.self_authorized_member.clone() {
(am, room_info.invite_chain.clone())
} else if is_owner {
let owner_id = MemberId::from(&room_owner_key);
let member = Member {
owner_member_id: owner_id,
invited_by: owner_id,
member_vk: room_owner_key,
};
(AuthorizedMember::new(member, &signing_key), vec![])
} else {
// Try fetching from network state
let state = api_client
.get_room(&room_owner_key, false)
.await
.map_err(|_| {
anyhow!(
"No authorized member data cached and could not fetch from network. \
Try sending a message first."
)
})?;
let vk = signing_key.verifying_key();
let params = ChatRoomParametersV1 {
owner: room_owner_key,
};
let m = state
.members
.members
.iter()
.find(|m| m.member.member_vk == vk)
.ok_or_else(|| {
anyhow!(
"You are not in this room's member list. \
Try sending a message first to populate membership data."
)
})?;
let chain = state
.members
.get_invite_chain(m, &params)
.map_err(|e| anyhow!("Could not resolve invite chain: {}", e))?;
(m.clone(), chain)
};

// Fetch fresh state from network to get current member_info (nickname) and room name
let (member_info, room_name) = match api_client.get_room(&room_owner_key, false).await {
Expand All @@ -94,7 +137,7 @@ async fn export_identity(
room_owner: room_owner_key,
signing_key,
authorized_member,
invite_chain: room_info.invite_chain.clone(),
invite_chain,
member_info,
room_name,
};
Expand Down
45 changes: 45 additions & 0 deletions common/src/room_state/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,4 +464,49 @@ mod tests {
let decoded = IdentityExport::from_armored_string(&armored).unwrap();
assert!(decoded.room_name.is_none());
}

#[test]
fn test_owner_self_signed_roundtrip() {
// Room owners create a self-signed AuthorizedMember for export.
// Verify this roundtrips correctly and the imported key can sign.
let owner_sk = SigningKey::generate(&mut OsRng);
let owner_vk = owner_sk.verifying_key();
let owner_id = MemberId::from(&owner_vk);

// Owner creates a self-signed AuthorizedMember (invited_by == self)
let member = Member {
owner_member_id: owner_id,
invited_by: owner_id,
member_vk: owner_vk,
};
let authorized_member = AuthorizedMember::new(member, &owner_sk);

let export = IdentityExport {
room_owner: owner_vk,
signing_key: owner_sk,
authorized_member,
invite_chain: vec![],
member_info: None,
room_name: Some("My Room".to_string()),
};

let armored = export.to_armored_string();
let decoded = IdentityExport::from_armored_string(&armored).unwrap();

assert_eq!(decoded.room_owner, owner_vk);
assert_eq!(decoded.signing_key.verifying_key(), owner_vk);
assert_eq!(decoded.authorized_member.member.member_vk, owner_vk);
assert!(decoded.invite_chain.is_empty());
assert_eq!(decoded.room_name.as_deref(), Some("My Room"));

// Verify the imported signing key produces valid signatures
let message = b"owner test message";
let signature = decoded.signing_key.sign(message);
assert!(decoded
.authorized_member
.member
.member_vk
.verify_strict(message, &signature)
.is_ok());
}
}
59 changes: 55 additions & 4 deletions ui/src/components/members.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,44 @@ fn ExportIdentityModal(is_active: Signal<bool>) -> Element {
return;
};
if let Some(room_data) = rooms_read.map.get(&owner_key) {
if let Some(ref authorized_member) = room_data.self_authorized_member {
let verifying_key = room_data.self_sk.verifying_key();

// Resolve the AuthorizedMember and invite chain for export:
// 1. Use cached self_authorized_member if available
// 2. For owners: create a self-signed AuthorizedMember
// 3. For non-owners: look up from current room state
let resolved = if let Some(ref am) = room_data.self_authorized_member {
Some((am.clone(), room_data.invite_chain.clone()))
} else if verifying_key == room_data.owner_vk {
let owner_id = MemberId::from(&owner_key);
let member = river_core::room_state::member::Member {
owner_member_id: owner_id,
invited_by: owner_id,
member_vk: owner_key,
};
Some((AuthorizedMember::new(member, &room_data.self_sk), vec![]))
} else {
// Look up member and invite chain from current room state
let params = ChatRoomParametersV1 { owner: owner_key };
room_data
.room_state
.members
.members
.iter()
.find(|m| m.member.member_vk == verifying_key)
.and_then(|m| {
// Require a valid invite chain — an export with a broken
// chain would fail validation on import
room_data
.room_state
.members
.get_invite_chain(m, &params)
.ok()
.map(|chain| (m.clone(), chain))
})
};

if let Some((authorized_member, invite_chain)) = resolved {
// Extract room name for inclusion in export (None if encrypted and undecryptable)
let sealed_name = &room_data
.room_state
Expand All @@ -367,12 +404,26 @@ fn ExportIdentityModal(is_active: Signal<bool>) -> Element {
let room_name = unseal_bytes_with_secrets(sealed_name, &room_data.secrets)
.ok()
.map(|bytes| String::from_utf8_lossy(&bytes).to_string());

// Look up member_info from cached or current state
let member_info = room_data.self_member_info.clone().or_else(|| {
let member_id = MemberId::from(&verifying_key);
room_data
.room_state
.member_info
.member_info
.iter()
.filter(|i| i.member_info.member_id == member_id)
.max_by_key(|i| i.member_info.version)
.cloned()
});

let export = IdentityExport {
room_owner: owner_key,
signing_key: room_data.self_sk.clone(),
authorized_member: authorized_member.clone(),
invite_chain: room_data.invite_chain.clone(),
member_info: room_data.self_member_info.clone(),
authorized_member,
invite_chain,
member_info,
room_name,
};
token_text.set(export.to_armored_string());
Expand Down
11 changes: 7 additions & 4 deletions ui/src/example_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,19 @@ fn create_room(room_name: &String, self_is: SelfIs) -> CreatedRoom {
owner_sk,
));

// If self is a member but not the owner, add self
// If self is a member (but not owner), add to members list and cache AuthorizedMember
let mut self_authorized_member = None;
if self_is == SelfIs::Member {
members.members.push(AuthorizedMember::new(
let am = AuthorizedMember::new(
Member {
owner_member_id: owner_id,
invited_by: owner_id,
member_vk: self_vk.clone(),
},
owner_sk,
));
);
self_authorized_member = Some(am.clone());
members.members.push(am);

member_info
.member_info
Expand Down Expand Up @@ -217,7 +220,7 @@ fn create_room(room_name: &String, self_is: SelfIs) -> CreatedRoom {
current_secret_version: None,
last_secret_rotation: None,
key_migrated_to_delegate: true, // Example data doesn't need migration
self_authorized_member: None,
self_authorized_member,
invite_chain: vec![],
self_member_info: None,
previous_contract_key: None,
Expand Down
Loading