Skip to main content

river_core/room_state/
identity.rs

1use ed25519_dalek::{SigningKey, VerifyingKey};
2use serde::{Deserialize, Serialize};
3
4use super::member::{AuthorizedMember, MemberId};
5use super::member_info::AuthorizedMemberInfo;
6
7const ARMOR_BEGIN: &str = "-----BEGIN RIVER IDENTITY-----";
8const ARMOR_END: &str = "-----END RIVER IDENTITY-----";
9const LINE_WIDTH: usize = 64;
10
11/// A portable identity bundle containing everything needed to restore
12/// a user's room identity on a different client.
13#[derive(Serialize, Deserialize, Clone, Debug)]
14pub struct IdentityExport {
15    /// The room owner's verifying key (identifies which room)
16    pub room_owner: VerifyingKey,
17    /// The user's private signing key
18    pub signing_key: SigningKey,
19    /// The user's signed membership proof
20    pub authorized_member: AuthorizedMember,
21    /// Chain of AuthorizedMembers from this member up to the owner,
22    /// needed for membership validation and rejoin after pruning
23    pub invite_chain: Vec<AuthorizedMember>,
24    /// Optional member info (nickname etc.)
25    pub member_info: Option<AuthorizedMemberInfo>,
26    /// Room display name (shown immediately on import before sync completes)
27    #[serde(default)]
28    pub room_name: Option<String>,
29}
30
31impl IdentityExport {
32    /// Encode as an armored string with header/footer and line wrapping.
33    pub fn to_armored_string(&self) -> String {
34        let mut data = Vec::new();
35        ciborium::ser::into_writer(self, &mut data).expect("Serialization should not fail");
36        let encoded = bs58::encode(data).into_string();
37
38        let mut result = String::new();
39        result.push_str(ARMOR_BEGIN);
40        result.push('\n');
41        for chunk in encoded.as_bytes().chunks(LINE_WIDTH) {
42            result.push_str(std::str::from_utf8(chunk).unwrap());
43            result.push('\n');
44        }
45        result.push_str(ARMOR_END);
46        result
47    }
48
49    /// Decode from an armored string, stripping header/footer and whitespace.
50    pub fn from_armored_string(s: &str) -> Result<Self, String> {
51        // Strip armor markers and whitespace
52        let payload: String = s
53            .lines()
54            .map(|line| line.trim())
55            .filter(|line| !line.is_empty() && !line.starts_with("-----"))
56            .collect();
57
58        if payload.is_empty() {
59            return Err("Empty identity token".to_string());
60        }
61
62        let decoded = bs58::decode(&payload)
63            .into_vec()
64            .map_err(|e| format!("Base58 decode error: {}", e))?;
65        let export: Self = ciborium::de::from_reader(&decoded[..])
66            .map_err(|e| format!("Deserialization error: {}", e))?;
67
68        // Validate that the signing key matches the authorized member's verifying key
69        if export.signing_key.verifying_key() != export.authorized_member.member.member_vk {
70            return Err(
71                "Signing key does not match the authorized member's verifying key".to_string(),
72            );
73        }
74
75        // Validate invite chain signatures where possible.
76        // The authorized_member is signed by its inviter. If the inviter is the owner
77        // we can verify directly; if it's a chain member, verify against that member's vk.
78        export.validate_invite_chain()?;
79
80        Ok(export)
81    }
82
83    /// Validate that invite chain signatures are internally consistent.
84    /// We verify each member's signature against its inviter's verifying key,
85    /// where the inviter is either the room owner or another chain member.
86    fn validate_invite_chain(&self) -> Result<(), String> {
87        let owner_id = MemberId::from(&self.room_owner);
88
89        // Build a lookup of chain members by MemberId -> VerifyingKey
90        let mut vk_by_id: std::collections::HashMap<MemberId, VerifyingKey> =
91            std::collections::HashMap::new();
92        vk_by_id.insert(owner_id, self.room_owner);
93        for chain_member in &self.invite_chain {
94            vk_by_id.insert(chain_member.member.id(), chain_member.member.member_vk);
95        }
96
97        // Verify the main member's signature
98        let inviter_id = self.authorized_member.member.invited_by;
99        if let Some(inviter_vk) = vk_by_id.get(&inviter_id) {
100            self.authorized_member
101                .verify_signature(inviter_vk)
102                .map_err(|e| format!("Invalid authorized_member signature: {}", e))?;
103        }
104
105        // Verify each chain member's signature
106        for chain_member in &self.invite_chain {
107            let inviter_id = chain_member.member.invited_by;
108            if let Some(inviter_vk) = vk_by_id.get(&inviter_id) {
109                chain_member
110                    .verify_signature(inviter_vk)
111                    .map_err(|e| format!("Invalid invite chain signature: {}", e))?;
112            }
113        }
114
115        Ok(())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::room_state::member::{Member, MemberId};
123    use crate::room_state::member_info::MemberInfo;
124    use crate::room_state::privacy::SealedBytes;
125    use ed25519_dalek::{Signer, SigningKey};
126    use rand::rngs::OsRng;
127
128    #[test]
129    fn test_roundtrip_armored() {
130        let owner_sk = SigningKey::generate(&mut OsRng);
131        let owner_vk = owner_sk.verifying_key();
132        let owner_id = MemberId::from(&owner_vk);
133
134        let member_sk = SigningKey::generate(&mut OsRng);
135        let member_vk = member_sk.verifying_key();
136
137        let member = Member {
138            owner_member_id: owner_id,
139            invited_by: owner_id,
140            member_vk,
141        };
142        let authorized_member = AuthorizedMember::new(member, &owner_sk);
143
144        let export = IdentityExport {
145            room_owner: owner_vk,
146            signing_key: member_sk,
147            authorized_member,
148            invite_chain: vec![],
149            member_info: None,
150            room_name: None,
151        };
152
153        let armored = export.to_armored_string();
154
155        // Verify format
156        assert!(armored.starts_with(ARMOR_BEGIN));
157        assert!(armored.trim_end().ends_with(ARMOR_END));
158
159        // Verify all lines are within width limit
160        for line in armored.lines() {
161            if !line.starts_with("-----") {
162                assert!(line.len() <= LINE_WIDTH, "Line too long: {}", line.len());
163            }
164        }
165
166        // Roundtrip
167        let decoded = IdentityExport::from_armored_string(&armored).unwrap();
168        assert_eq!(decoded.room_owner.as_bytes(), export.room_owner.as_bytes());
169        assert_eq!(
170            decoded.signing_key.to_bytes(),
171            export.signing_key.to_bytes()
172        );
173        assert_eq!(decoded.authorized_member, export.authorized_member);
174        assert_eq!(decoded.invite_chain.len(), 0);
175        assert!(decoded.member_info.is_none());
176        assert!(decoded.room_name.is_none());
177    }
178
179    #[test]
180    fn test_rejects_mismatched_key() {
181        let owner_sk = SigningKey::generate(&mut OsRng);
182        let owner_vk = owner_sk.verifying_key();
183        let owner_id = MemberId::from(&owner_vk);
184
185        let member_sk = SigningKey::generate(&mut OsRng);
186        let wrong_sk = SigningKey::generate(&mut OsRng);
187        let member_vk = member_sk.verifying_key();
188
189        let member = Member {
190            owner_member_id: owner_id,
191            invited_by: owner_id,
192            member_vk,
193        };
194        let authorized_member = AuthorizedMember::new(member, &owner_sk);
195
196        // Use the wrong signing key
197        let export = IdentityExport {
198            room_owner: owner_vk,
199            signing_key: wrong_sk,
200            authorized_member,
201            invite_chain: vec![],
202            member_info: None,
203            room_name: None,
204        };
205
206        let armored = export.to_armored_string();
207        let result = IdentityExport::from_armored_string(&armored);
208        assert!(result.is_err());
209        assert!(result.unwrap_err().contains("does not match"));
210    }
211
212    #[test]
213    fn test_roundtrip_with_invite_chain_and_member_info() {
214        let owner_sk = SigningKey::generate(&mut OsRng);
215        let owner_vk = owner_sk.verifying_key();
216        let owner_id = MemberId::from(&owner_vk);
217
218        // Create a chain: owner -> member_a -> member_b
219        let member_a_sk = SigningKey::generate(&mut OsRng);
220        let member_a = Member {
221            owner_member_id: owner_id,
222            invited_by: owner_id,
223            member_vk: member_a_sk.verifying_key(),
224        };
225        let auth_member_a = AuthorizedMember::new(member_a, &owner_sk);
226
227        let member_b_sk = SigningKey::generate(&mut OsRng);
228        let member_b = Member {
229            owner_member_id: owner_id,
230            invited_by: MemberId::from(&member_a_sk.verifying_key()),
231            member_vk: member_b_sk.verifying_key(),
232        };
233        let auth_member_b = AuthorizedMember::new(member_b, &member_a_sk);
234
235        // Create member info with a nickname
236        let member_info = MemberInfo {
237            member_id: MemberId::from(&member_b_sk.verifying_key()),
238            version: 1,
239            preferred_nickname: SealedBytes::public("TestUser".as_bytes().to_vec()),
240        };
241        let auth_member_info = AuthorizedMemberInfo::new_with_member_key(member_info, &member_b_sk);
242
243        let export = IdentityExport {
244            room_owner: owner_vk,
245            signing_key: member_b_sk.clone(),
246            authorized_member: auth_member_b.clone(),
247            invite_chain: vec![auth_member_a.clone()],
248            member_info: Some(auth_member_info.clone()),
249            room_name: Some("Test Room".to_string()),
250        };
251
252        let armored = export.to_armored_string();
253        let decoded = IdentityExport::from_armored_string(&armored).unwrap();
254
255        // Verify all fields survive roundtrip
256        assert_eq!(decoded.invite_chain.len(), 1);
257        assert_eq!(decoded.invite_chain[0], auth_member_a);
258        assert_eq!(decoded.authorized_member, auth_member_b);
259        assert!(decoded.member_info.is_some());
260        assert_eq!(
261            decoded
262                .member_info
263                .unwrap()
264                .member_info
265                .preferred_nickname
266                .to_string_lossy(),
267            "TestUser"
268        );
269        assert_eq!(decoded.room_name.as_deref(), Some("Test Room"));
270    }
271
272    #[test]
273    fn test_imported_key_can_sign() {
274        let owner_sk = SigningKey::generate(&mut OsRng);
275        let owner_vk = owner_sk.verifying_key();
276        let owner_id = MemberId::from(&owner_vk);
277
278        let member_sk = SigningKey::generate(&mut OsRng);
279        let member = Member {
280            owner_member_id: owner_id,
281            invited_by: owner_id,
282            member_vk: member_sk.verifying_key(),
283        };
284        let authorized_member = AuthorizedMember::new(member, &owner_sk);
285
286        let export = IdentityExport {
287            room_owner: owner_vk,
288            signing_key: member_sk,
289            authorized_member,
290            invite_chain: vec![],
291            member_info: None,
292            room_name: None,
293        };
294
295        let armored = export.to_armored_string();
296        let decoded = IdentityExport::from_armored_string(&armored).unwrap();
297
298        // Verify the imported key can produce valid signatures
299        let message = b"test message";
300        let signature = decoded.signing_key.sign(message);
301        assert!(decoded
302            .authorized_member
303            .member
304            .member_vk
305            .verify_strict(message, &signature)
306            .is_ok());
307    }
308
309    #[test]
310    fn test_rejects_tampered_signature() {
311        use ed25519_dalek::Signature;
312
313        let owner_sk = SigningKey::generate(&mut OsRng);
314        let owner_vk = owner_sk.verifying_key();
315        let owner_id = MemberId::from(&owner_vk);
316
317        let member_sk = SigningKey::generate(&mut OsRng);
318        let member = Member {
319            owner_member_id: owner_id,
320            invited_by: owner_id,
321            member_vk: member_sk.verifying_key(),
322        };
323        // Create a valid authorized member then tamper with the signature
324        let mut bad_auth_member = AuthorizedMember::new(member, &owner_sk);
325        // Replace signature with garbage
326        bad_auth_member.signature = Signature::from_bytes(&[0u8; 64]);
327
328        let export = IdentityExport {
329            room_owner: owner_vk,
330            signing_key: member_sk,
331            authorized_member: bad_auth_member,
332            invite_chain: vec![],
333            member_info: None,
334            room_name: None,
335        };
336
337        let armored = export.to_armored_string();
338        let result = IdentityExport::from_armored_string(&armored);
339        assert!(result.is_err());
340        assert!(result.unwrap_err().contains("signature"));
341    }
342
343    #[test]
344    fn test_rejects_truncated_token() {
345        let owner_sk = SigningKey::generate(&mut OsRng);
346        let owner_vk = owner_sk.verifying_key();
347        let owner_id = MemberId::from(&owner_vk);
348
349        let member_sk = SigningKey::generate(&mut OsRng);
350        let member = Member {
351            owner_member_id: owner_id,
352            invited_by: owner_id,
353            member_vk: member_sk.verifying_key(),
354        };
355        let authorized_member = AuthorizedMember::new(member, &owner_sk);
356
357        let export = IdentityExport {
358            room_owner: owner_vk,
359            signing_key: member_sk,
360            authorized_member,
361            invite_chain: vec![],
362            member_info: None,
363            room_name: None,
364        };
365
366        let armored = export.to_armored_string();
367
368        // Truncate the token in the middle
369        let lines: Vec<&str> = armored.lines().collect();
370        let truncated = format!(
371            "{}\n{}\n{}",
372            lines[0],
373            &lines[1][..lines[1].len() / 2],
374            lines.last().unwrap()
375        );
376        let result = IdentityExport::from_armored_string(&truncated);
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn test_rejects_empty_token() {
382        let result = IdentityExport::from_armored_string("");
383        assert!(result.is_err());
384        assert!(result.unwrap_err().contains("Empty"));
385
386        let result = IdentityExport::from_armored_string(
387            "-----BEGIN RIVER IDENTITY-----\n-----END RIVER IDENTITY-----",
388        );
389        assert!(result.is_err());
390        assert!(result.unwrap_err().contains("Empty"));
391    }
392
393    #[test]
394    fn test_handles_whitespace_and_formatting() {
395        let owner_sk = SigningKey::generate(&mut OsRng);
396        let owner_vk = owner_sk.verifying_key();
397        let owner_id = MemberId::from(&owner_vk);
398
399        let member_sk = SigningKey::generate(&mut OsRng);
400        let member = Member {
401            owner_member_id: owner_id,
402            invited_by: owner_id,
403            member_vk: member_sk.verifying_key(),
404        };
405        let authorized_member = AuthorizedMember::new(member, &owner_sk);
406
407        let export = IdentityExport {
408            room_owner: owner_vk,
409            signing_key: member_sk,
410            authorized_member,
411            invite_chain: vec![],
412            member_info: None,
413            room_name: None,
414        };
415
416        let armored = export.to_armored_string();
417
418        // Add extra whitespace and blank lines (simulating copy-paste issues)
419        let messy = format!("\n  {}  \n\n", armored.replace('\n', "\n  "));
420        let decoded = IdentityExport::from_armored_string(&messy).unwrap();
421        assert_eq!(
422            decoded.signing_key.to_bytes(),
423            export.signing_key.to_bytes()
424        );
425    }
426
427    #[test]
428    fn test_backward_compat_no_room_name() {
429        // Simulate a token exported from an older version that doesn't include room_name.
430        // CBOR map without the room_name field should decode with room_name = None.
431        let owner_sk = SigningKey::generate(&mut OsRng);
432        let owner_vk = owner_sk.verifying_key();
433        let owner_id = MemberId::from(&owner_vk);
434
435        let member_sk = SigningKey::generate(&mut OsRng);
436        let member = Member {
437            owner_member_id: owner_id,
438            invited_by: owner_id,
439            member_vk: member_sk.verifying_key(),
440        };
441        let authorized_member = AuthorizedMember::new(member, &owner_sk);
442
443        // Manually build a CBOR-serializable struct without room_name
444        #[derive(Serialize)]
445        struct OldExport {
446            room_owner: VerifyingKey,
447            signing_key: SigningKey,
448            authorized_member: AuthorizedMember,
449            invite_chain: Vec<AuthorizedMember>,
450            member_info: Option<AuthorizedMemberInfo>,
451        }
452        let old = OldExport {
453            room_owner: owner_vk,
454            signing_key: member_sk,
455            authorized_member,
456            invite_chain: vec![],
457            member_info: None,
458        };
459        let mut data = Vec::new();
460        ciborium::ser::into_writer(&old, &mut data).unwrap();
461        let encoded = bs58::encode(&data).into_string();
462        let armored = format!("{}\n{}\n{}", ARMOR_BEGIN, encoded, ARMOR_END);
463
464        let decoded = IdentityExport::from_armored_string(&armored).unwrap();
465        assert!(decoded.room_name.is_none());
466    }
467
468    #[test]
469    fn test_owner_self_signed_roundtrip() {
470        // Room owners create a self-signed AuthorizedMember for export.
471        // Verify this roundtrips correctly and the imported key can sign.
472        let owner_sk = SigningKey::generate(&mut OsRng);
473        let owner_vk = owner_sk.verifying_key();
474        let owner_id = MemberId::from(&owner_vk);
475
476        // Owner creates a self-signed AuthorizedMember (invited_by == self)
477        let member = Member {
478            owner_member_id: owner_id,
479            invited_by: owner_id,
480            member_vk: owner_vk,
481        };
482        let authorized_member = AuthorizedMember::new(member, &owner_sk);
483
484        let export = IdentityExport {
485            room_owner: owner_vk,
486            signing_key: owner_sk,
487            authorized_member,
488            invite_chain: vec![],
489            member_info: None,
490            room_name: Some("My Room".to_string()),
491        };
492
493        let armored = export.to_armored_string();
494        let decoded = IdentityExport::from_armored_string(&armored).unwrap();
495
496        assert_eq!(decoded.room_owner, owner_vk);
497        assert_eq!(decoded.signing_key.verifying_key(), owner_vk);
498        assert_eq!(decoded.authorized_member.member.member_vk, owner_vk);
499        assert!(decoded.invite_chain.is_empty());
500        assert_eq!(decoded.room_name.as_deref(), Some("My Room"));
501
502        // Verify the imported signing key produces valid signatures
503        let message = b"owner test message";
504        let signature = decoded.signing_key.sign(message);
505        assert!(decoded
506            .authorized_member
507            .member
508            .member_vk
509            .verify_strict(message, &signature)
510            .is_ok());
511    }
512}