Skip to main content

edda_auth/
pairing.rs

1//! Pairing a new device with a short code, using a PAKE (SPAKE2).
2//!
3//! The DM's device shows a code like `7-tavern-dragon`. The number picks the
4//! pairing on the auth service and is public; the two words are a password
5//! that never leaves the two devices. Both run SPAKE2 with the words, with
6//! the auth service relaying the messages, and end up with the same strong
7//! key only if they used the same words. An eavesdropper (the relay
8//! included) learns nothing it could guess the words against offline; an
9//! active attacker gets one online guess, after which the pairing is gone.
10//!
11//! Over the resulting encrypted channel the new device sends its public key
12//! and name, and the DM's device answers with a certificate for it, the
13//! campaign, and the auth service's key, which the new device then trusts
14//! because it came from the DM, not from whatever server it happens to be
15//! talking to.
16//!
17//! Everything here is IO-free; `edda-client` moves the bytes.
18
19use 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
29/// Words in a code. With 1,296 words, two give a 1 in 1,679,616 chance to
30/// an attacker, who only gets one try per code.
31pub 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/// What can go wrong reading a pairing code or a sealed message.
41#[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    /// Decryption failed: the other side used different words (or the
48    /// messages were tampered with).
49    #[error("the codes don't match")]
50    WrongCode,
51    #[error("the other side sent something unexpected")]
52    BadMessage,
53}
54
55/// `<slot>-<word>-<word>`.
56#[derive(Clone, PartialEq, Eq)]
57pub struct PairingCode {
58    pub slot: u16,
59    words: [String; WORDS],
60}
61
62impl PairingCode {
63    /// A code for `slot` with fresh random words.
64    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    /// Forgiving about case, spaces and separators: "7 Tavern dragon" works.
91    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/// The two sides of a pairing. SPAKE2 is asymmetric, and each side's
113/// identity is bound into the key.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Side {
116    /// The DM's device, which made the code.
117    Host,
118    /// The device joining.
119    Joiner,
120}
121
122impl Side {
123    /// The side's name, `host` or `joiner`: in the relay's paths, and bound
124    /// into the keys.
125    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
140/// One side's SPAKE2 run, between sending its message and receiving the
141/// other's.
142pub struct Exchange {
143    side: Side,
144    slot: u16,
145    spake: Spake2<Ed25519Group>,
146}
147
148impl Exchange {
149    /// Starts the exchange; send the returned message to the other side.
150    pub fn start(side: Side, code: &PairingCode) -> (Self, Vec<u8>) {
151        // Binding the slot means a code's words are only good for its slot.
152        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    /// Finishes with the other side's message. Always succeeds for a
167    /// well-formed message, even if the words differ; that only shows when
168    /// the first encrypted message fails to open.
169    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
191/// Authenticated encryption both ways, with a key per direction. Nonces are
192/// message counters, so no key ever sees a nonce twice.
193pub struct Channel {
194    send: ChaCha20Poly1305,
195    receive: ChaCha20Poly1305,
196    sent: u64,
197    received: u64,
198}
199
200impl Channel {
201    /// Encrypts `message` for the other side.
202    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    /// Decrypts a message from the other side; fails if it was sealed with
213    /// another code.
214    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/// The joiner's first encrypted message.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
233pub struct JoinRequest {
234    pub key: PublicKey,
235    /// "Vex's laptop".
236    pub name: String,
237}
238
239/// The DM's answer.
240#[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        /// The auth service's key, as the DM's device knows it.
247        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        // A message can't be bounced back to its sender as if from the
313        // other side: each direction has its own key.
314        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}