1use std::{fmt, str::FromStr, sync::LazyLock};
20
21use chacha20poly1305::{ChaCha20Poly1305, KeyInit, aead::Aead};
22use hkdf::Hkdf;
23use serde::{Deserialize, Serialize, de::DeserializeOwned};
24use sha2::Sha256;
25use spake2::{Ed25519Group, Identity, Password, Spake2};
26
27use crate::{PublicKey, SignedCert, protocol::Campaign};
28
29pub const WORDS: usize = 2;
32
33static WORDLIST: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
34 include_str!("eff_short_wordlist_1.txt")
35 .lines()
36 .filter(|line| !line.starts_with('#') && !line.is_empty())
37 .collect()
38});
39
40#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
42pub enum PairingError {
43 #[error("a pairing code looks like 7-tavern-dragon")]
44 Malformed,
45 #[error("{0:?} is not one of the code words; check the spelling")]
46 UnknownWord(String),
47 #[error("the codes don't match")]
50 WrongCode,
51 #[error("the other side sent something unexpected")]
52 BadMessage,
53}
54
55#[derive(Clone, PartialEq, Eq)]
57pub struct PairingCode {
58 pub slot: u16,
59 words: [String; WORDS],
60}
61
62impl PairingCode {
63 pub fn generate(slot: u16) -> Self {
65 let list = &*WORDLIST;
66 let words = std::array::from_fn(|_| list[rand::random_range(0..list.len())].to_string());
67 Self { slot, words }
68 }
69
70 fn password(&self) -> Password {
71 Password::new(self.words.join("-"))
72 }
73}
74
75impl fmt::Display for PairingCode {
76 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
77 write!(f, "{}-{}", self.slot, self.words.join("-"))
78 }
79}
80
81impl fmt::Debug for PairingCode {
82 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
83 write!(f, "PairingCode({}-…)", self.slot)
84 }
85}
86
87impl FromStr for PairingCode {
88 type Err = PairingError;
89
90 fn from_str(s: &str) -> Result<Self, Self::Err> {
92 let parts: Vec<String> = s
93 .split(|c: char| c == '-' || c.is_whitespace())
94 .filter(|p| !p.is_empty())
95 .map(str::to_lowercase)
96 .collect();
97 let [slot, words @ ..] = parts.as_slice() else {
98 return Err(PairingError::Malformed);
99 };
100 let slot = slot.parse().map_err(|_| PairingError::Malformed)?;
101 let words: [String; WORDS] = words
102 .to_vec()
103 .try_into()
104 .map_err(|_| PairingError::Malformed)?;
105 if let Some(unknown) = words.iter().find(|w| !WORDLIST.contains(&w.as_str())) {
106 return Err(PairingError::UnknownWord(unknown.clone()));
107 }
108 Ok(Self { slot, words })
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Side {
116 Host,
118 Joiner,
120}
121
122impl Side {
123 pub fn as_str(self) -> &'static str {
126 match self {
127 Side::Host => "host",
128 Side::Joiner => "joiner",
129 }
130 }
131
132 fn other(self) -> Side {
133 match self {
134 Side::Host => Side::Joiner,
135 Side::Joiner => Side::Host,
136 }
137 }
138}
139
140pub struct Exchange {
143 side: Side,
144 slot: u16,
145 spake: Spake2<Ed25519Group>,
146}
147
148impl Exchange {
149 pub fn start(side: Side, code: &PairingCode) -> (Self, Vec<u8>) {
151 let host = Identity::new(format!("edda/v1/pair/host/{}", code.slot).as_bytes());
153 let joiner = Identity::new(format!("edda/v1/pair/joiner/{}", code.slot).as_bytes());
154 let (spake, message) = match side {
155 Side::Host => Spake2::start_a(&code.password(), &host, &joiner),
156 Side::Joiner => Spake2::start_b(&code.password(), &host, &joiner),
157 };
158 let exchange = Self {
159 side,
160 slot: code.slot,
161 spake,
162 };
163 (exchange, message)
164 }
165
166 pub fn finish(self, theirs: &[u8]) -> Result<Channel, PairingError> {
170 let shared = self
171 .spake
172 .finish(theirs)
173 .map_err(|_| PairingError::BadMessage)?;
174 let hkdf = Hkdf::<Sha256>::new(Some(&self.slot.to_be_bytes()), &shared);
175 let key = |side: Side| {
176 let mut key = [0u8; 32];
177 let info = format!("edda/v1/pair/{}-to-other", side.as_str());
178 hkdf.expand(info.as_bytes(), &mut key)
179 .expect("32 bytes is a valid length");
180 ChaCha20Poly1305::new(&key.into())
181 };
182 Ok(Channel {
183 send: key(self.side),
184 receive: key(self.side.other()),
185 sent: 0,
186 received: 0,
187 })
188 }
189}
190
191pub struct Channel {
194 send: ChaCha20Poly1305,
195 receive: ChaCha20Poly1305,
196 sent: u64,
197 received: u64,
198}
199
200impl Channel {
201 pub fn seal(&mut self, message: &impl Serialize) -> Vec<u8> {
203 let plain = serde_json::to_vec(message).expect("pairing messages serialize");
204 let sealed = self
205 .send
206 .encrypt(&nonce(self.sent), plain.as_slice())
207 .expect("encryption can't fail for in-memory buffers");
208 self.sent += 1;
209 sealed
210 }
211
212 pub fn open<T: DeserializeOwned>(&mut self, sealed: &[u8]) -> Result<T, PairingError> {
215 let plain = self
216 .receive
217 .decrypt(&nonce(self.received), sealed)
218 .map_err(|_| PairingError::WrongCode)?;
219 self.received += 1;
220 serde_json::from_slice(&plain).map_err(|_| PairingError::BadMessage)
221 }
222}
223
224fn nonce(counter: u64) -> chacha20poly1305::Nonce {
225 let mut nonce = [0u8; 12];
226 nonce[4..].copy_from_slice(&counter.to_be_bytes());
227 nonce.into()
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
233pub struct JoinRequest {
234 pub key: PublicKey,
235 pub name: String,
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
242#[serde(tag = "type", rename_all = "snake_case")]
243pub enum JoinAnswer {
244 Approved {
245 campaign: Campaign,
246 server_key: PublicKey,
248 cert: Box<SignedCert>,
249 },
250 Declined,
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn pair(host_code: &PairingCode, joiner_code: &PairingCode) -> (Channel, Channel) {
258 let (host, to_joiner) = Exchange::start(Side::Host, host_code);
259 let (joiner, to_host) = Exchange::start(Side::Joiner, joiner_code);
260 (
261 host.finish(&to_host).unwrap(),
262 joiner.finish(&to_joiner).unwrap(),
263 )
264 }
265
266 #[test]
267 fn the_same_code_gives_both_sides_a_working_channel() {
268 let code = PairingCode::generate(7);
269 let (mut host, mut joiner) = pair(&code, &code);
270 let request = JoinRequest {
271 key: crate::DeviceKey::generate().public(),
272 name: "Vex's laptop".into(),
273 };
274 let sealed = joiner.seal(&request);
275 assert_eq!(host.open::<JoinRequest>(&sealed), Ok(request));
276 let sealed = host.seal(&JoinAnswer::Declined);
277 assert_eq!(joiner.open::<JoinAnswer>(&sealed), Ok(JoinAnswer::Declined));
278 }
279
280 #[test]
281 fn a_different_word_or_slot_gives_nothing() {
282 let code: PairingCode = "7-acid-acorn".parse().unwrap();
283 for wrong in ["7-acid-acre", "8-acid-acorn"] {
284 let (mut host, mut joiner) = pair(&code, &wrong.parse().unwrap());
285 let sealed = joiner.seal(&"hello");
286 assert_eq!(
287 host.open::<String>(&sealed),
288 Err(PairingError::WrongCode),
289 "{wrong}"
290 );
291 }
292 }
293
294 #[test]
295 fn messages_cannot_be_tampered_with_reordered_or_reflected() {
296 let code = PairingCode::generate(3);
297 let (mut host, mut joiner) = pair(&code, &code);
298 let mut sealed = joiner.seal(&"first");
299 sealed[0] ^= 1;
300 assert_eq!(host.open::<String>(&sealed), Err(PairingError::WrongCode));
301
302 let (mut host, mut joiner) = pair(&code, &code);
303 let first = joiner.seal(&"first");
304 let second = joiner.seal(&"second");
305 assert_eq!(
306 host.open::<String>(&second),
307 Err(PairingError::WrongCode),
308 "out of order"
309 );
310 assert_eq!(host.open::<String>(&first), Ok("first".into()));
311
312 let (_, mut joiner) = pair(&code, &code);
315 let own = joiner.seal(&"echo");
316 assert_eq!(joiner.open::<String>(&own), Err(PairingError::WrongCode));
317 }
318
319 #[test]
320 fn codes_parse_forgivingly_and_reject_typos() {
321 let code = PairingCode::generate(42);
322 let text = code.to_string();
323 assert_eq!(text.parse::<PairingCode>(), Ok(code.clone()));
324 assert_eq!(
325 text.to_uppercase().replace('-', " ").parse::<PairingCode>(),
326 Ok(code)
327 );
328 assert_eq!(
329 "7-tavrn-acid".parse::<PairingCode>(),
330 Err(PairingError::UnknownWord("tavrn".into()))
331 );
332 for malformed in [
333 "",
334 "acid-acorn",
335 "7-acid",
336 "7-acid-acorn-acre",
337 "x-acid-acorn",
338 ] {
339 assert_eq!(
340 malformed.parse::<PairingCode>(),
341 Err(PairingError::Malformed)
342 );
343 }
344 assert_eq!(WORDLIST.len(), 1296);
345 assert!(
346 format!("{:?}", PairingCode::generate(1)).ends_with("1-…)"),
347 "Debug hides the words"
348 );
349 }
350}