zoe_app_primitives/invitation.rs
1//! Group invitation utilities and emoji verification
2//!
3//! This module provides utilities for the group invitation flow, including
4//! the cryptographic emoji derivation function used for PQXDH verification.
5
6use blake3::Hasher;
7
8/// 64 carefully chosen emojis for maximum visual distinction
9///
10/// These emojis are organized into categories to avoid confusion and ensure
11/// cross-platform consistency. They are chosen for:
12/// - Visual distinction (no similar-looking emojis)
13/// - Cross-platform consistency (common emojis that render similarly)
14/// - Accessibility (high contrast, distinct shapes)
15/// - Cultural neutrality (avoid emojis with cultural/religious significance)
16pub const EMOJI_SET: [&str; 64] = [
17 // Objects & Symbols (16)
18 "🔑", "🌟", "🚀", "🎯", "🌈", "🔒", "⚡", "🎨", "🌸", "🔥", "💎", "🎪", "🌊", "🎭", "🍀", "🌺",
19 // Animals & Nature (16)
20 "🐱", "🐶", "🦋", "🐸", "🦊", "🐧", "🦁", "🐯", "🐨", "🐼", "🦉", "🐺", "🦄", "🐙", "🦀", "🐢",
21 // Food & Drinks (16)
22 "🍎", "🍌", "🍇", "🍓", "🥝", "🍑", "🥕", "🌽", "🍄", "🥑", "🍕", "🍔", "🎂", "🍪", "☕", "🍯",
23 // Activities & Objects (16)
24 "⚽", "🏀", "🎸", "🎹", "🎲", "🎮", "📱", "💻", "⌚", "📷", "🎧", "🔍", "💡", "🔧", "⚖️", "🎁",
25];
26
27/// Derive a 6-emoji verification sequence from a PQXDH shared secret
28///
29/// This function takes a 32-byte shared secret and derives a sequence of 6 emojis
30/// that can be displayed to users for manual verification. The derivation uses
31/// BLAKE3 with domain separation to ensure the emojis cannot be used to recover
32/// the original shared secret.
33///
34/// # Security Properties
35///
36/// - **One-way function**: BLAKE3 is cryptographically one-way
37/// - **Domain separation**: Uses unique context string for verification
38/// - **Limited exposure**: Only 48 bits of derived data used for emojis
39/// - **Uniform distribution**: Each emoji has equal probability (1/64)
40/// - **High collision resistance**: 64^6 = 68.7 billion possible sequences
41///
42/// # Algorithm
43///
44/// 1. Derive 32-byte fingerprint using BLAKE3 with domain separation
45/// 2. Split fingerprint into 6 chunks of ~5.33 bytes each
46/// 3. Convert each chunk to little-endian integer
47/// 4. Map integer modulo 64 to emoji index
48///
49/// # Arguments
50///
51/// * `shared_secret` - 32-byte PQXDH shared secret
52///
53/// # Returns
54///
55/// Array of 6 emoji strings for user verification
56///
57/// # Example
58///
59/// ```rust
60/// use zoe_app_primitives::invitation::derive_emoji_verification;
61///
62/// let shared_secret = [0u8; 32]; // Example shared secret
63/// let emojis = derive_emoji_verification(&shared_secret);
64/// println!("Verify these emojis match: {}", emojis.join(" "));
65/// ```
66pub fn derive_emoji_verification(shared_secret: &[u8; 32]) -> [&'static str; 6] {
67 // Derive verification fingerprint using BLAKE3 with domain separation
68 let mut hasher = Hasher::new();
69 hasher.update(shared_secret);
70 hasher.update(b"PQXDH-VERIFICATION-FINGERPRINT-v1");
71 let verification_fingerprint = hasher.finalize();
72 let fingerprint_bytes = verification_fingerprint.as_bytes();
73
74 // Split into 6 chunks and derive emoji for each chunk
75 let mut emojis = [""; 6];
76 for (i, emoji) in emojis.iter_mut().enumerate() {
77 let start = i * 5;
78 let end = std::cmp::min(start + 5, 32);
79 let chunk = &fingerprint_bytes[start..end];
80
81 // Combine bytes in chunk to get index (little-endian)
82 let mut index = 0u64;
83 for (j, &byte) in chunk.iter().enumerate() {
84 index += (byte as u64) << (j * 8);
85 }
86
87 *emoji = EMOJI_SET[(index % 64) as usize];
88 }
89
90 emojis
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn test_emoji_derivation_deterministic() {
99 let shared_secret = [42u8; 32];
100
101 let emojis1 = derive_emoji_verification(&shared_secret);
102 let emojis2 = derive_emoji_verification(&shared_secret);
103
104 assert_eq!(emojis1, emojis2, "Emoji derivation should be deterministic");
105 }
106
107 #[test]
108 fn test_emoji_derivation_different_secrets() {
109 let secret1 = [1u8; 32];
110 let secret2 = [2u8; 32];
111
112 let emojis1 = derive_emoji_verification(&secret1);
113 let emojis2 = derive_emoji_verification(&secret2);
114
115 assert_ne!(
116 emojis1, emojis2,
117 "Different secrets should produce different emojis"
118 );
119 }
120
121 #[test]
122 fn test_emoji_set_size() {
123 assert_eq!(
124 EMOJI_SET.len(),
125 64,
126 "Emoji set should contain exactly 64 emojis"
127 );
128 }
129
130 #[test]
131 fn test_emoji_set_uniqueness() {
132 let mut unique_emojis = std::collections::HashSet::new();
133 for emoji in &EMOJI_SET {
134 assert!(
135 unique_emojis.insert(emoji),
136 "Emoji set should not contain duplicates: {emoji}"
137 );
138 }
139 }
140 #[test]
141 fn test_domain_separation() {
142 let shared_secret = [100u8; 32];
143
144 // Test that different domain strings produce different results
145 let mut hasher1 = Hasher::new();
146 hasher1.update(&shared_secret);
147 hasher1.update(b"PQXDH-VERIFICATION-FINGERPRINT-v1");
148 let fingerprint1 = hasher1.finalize();
149
150 let mut hasher2 = Hasher::new();
151 hasher2.update(&shared_secret);
152 hasher2.update(b"DIFFERENT-DOMAIN-STRING");
153 let fingerprint2 = hasher2.finalize();
154
155 assert_ne!(
156 fingerprint1.as_bytes(),
157 fingerprint2.as_bytes(),
158 "Different domain strings should produce different fingerprints"
159 );
160 }
161}