Skip to main content

a7n_core/
pairing.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Pure pairing state machines (PROTOCOL.md 5). The caller supplies time,
4//! certificate pins, TLS exporter binding, and a CSPRNG; no I/O happens here.
5
6use std::{
7    collections::{HashMap, VecDeque},
8    fmt,
9    hash::Hash,
10    time::Instant,
11};
12
13use a7n_proto::{
14    Pin, limits,
15    v1::{PairAnswer, PairConfirm, PairDone, PairError, PairStart, PeerInfo},
16};
17use rand_core::{CryptoRng, RngCore};
18use zeroize::{Zeroize, Zeroizing};
19
20mod code;
21mod crypto;
22
23pub use code::{InvalidPairingCode, PairingCode};
24#[cfg(test)]
25mod tests;
26
27use crypto::{Confirmation, TAG_LENGTH};
28
29/// Peer whose proof has verified. Machine id must come from its TLS certificate.
30#[derive(Debug)]
31pub struct Peer {
32    pub pin: Pin,
33    pub info: PeerInfo,
34}
35
36/// Host success: persist `peer` before consuming this value with [`Self::done`].
37pub struct Paired {
38    pub peer: Peer,
39    confirmation: Confirmation,
40}
41
42impl Paired {
43    /// Produce the storage acknowledgement only after the caller persisted `peer`.
44    #[must_use]
45    pub fn done(self) -> PairDone {
46        PairDone {
47            error: PairError::None.into(),
48            tag: self.confirmation.tag(true, true),
49        }
50    }
51}
52
53impl fmt::Debug for Paired {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.debug_struct("Paired").finish_non_exhaustive()
56    }
57}
58
59/// One host code window, permitting at most one exchange.
60pub struct HostPairing {
61    password: Zeroizing<curve25519_dalek::scalar::Scalar>,
62    qr: bool,
63    pin: Pin,
64    opened_at: Instant,
65    attempts: usize,
66    pending: Option<(Confirmation, Peer)>,
67    closed: bool,
68}
69
70impl fmt::Debug for HostPairing {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.debug_struct("HostPairing").finish_non_exhaustive()
73    }
74}
75
76impl HostPairing {
77    #[must_use]
78    pub fn new(code: PairingCode, qr: Option<[u8; 16]>, pin: Pin, opened_at: Instant) -> Self {
79        let qr = Zeroizing::new(qr);
80        let password = Zeroizing::new(crypto::password(&code, qr.as_ref()));
81        drop(code);
82        Self {
83            password,
84            qr: qr.is_some(),
85            pin,
86            opened_at,
87            attempts: 0,
88            pending: None,
89            closed: false,
90        }
91    }
92
93    /// Permanently leave pairing mode, including on transport or decoding failure.
94    pub fn refuse(&mut self) {
95        self.closed = true;
96        self.pending = None;
97        self.password.zeroize();
98    }
99
100    /// Consume the sole guess and produce the host proof.
101    ///
102    /// # Errors
103    /// Returns `NotPairing` after the window is closed, otherwise only `Refused`.
104    #[allow(
105        clippy::too_many_arguments,
106        reason = "the I/O shell supplies explicit identity, binding, time and randomness"
107    )]
108    pub fn start(
109        &mut self,
110        start: PairStart,
111        requester_pin: Pin,
112        ctx: [u8; 32],
113        info: PeerInfo,
114        now: Instant,
115        rng: &mut (impl CryptoRng + RngCore),
116    ) -> Result<PairAnswer, PairError> {
117        if self.closed {
118            self.refuse();
119            return Err(PairError::NotPairing);
120        }
121        if self.attempts >= limits::PAIRING_CODE_GUESSES
122            || self.pending.is_some()
123            || self.expired(now)
124        {
125            self.refuse();
126            return Err(PairError::Refused);
127        }
128        self.attempts += 1;
129        // A failed start must close the window even when validation exits early.
130        self.closed = true;
131        let result = self.answer(start, requester_pin, ctx, info, rng);
132        if result.is_err() {
133            self.refuse();
134        }
135        result
136    }
137
138    fn answer(
139        &mut self,
140        start: PairStart,
141        pin: Pin,
142        ctx: [u8; 32],
143        info: PeerInfo,
144        rng: &mut (impl CryptoRng + RngCore),
145    ) -> Result<PairAnswer, PairError> {
146        let point = crypto::decode_share(&start.share).ok_or(PairError::Refused)?;
147        if start.qr != self.qr {
148            return Err(PairError::Refused);
149        }
150        let peer = Peer {
151            pin,
152            info: start.info.ok_or(PairError::Refused)?,
153        };
154        let secret = Zeroizing::new(crypto::random_scalar(rng));
155        let share = crypto::share(&secret, &self.password, false);
156        // RFC 9382 section 3.3: K = h*y*(pA - w*M).
157        let shared = (*secret * (point - *self.password * crypto::mask(true))).mul_by_cofactor();
158        let confirmation = Confirmation::new(
159            (pin.as_bytes(), self.pin.as_bytes()),
160            (&start.share, &share),
161            shared,
162            &self.password,
163            &ctx,
164        )
165        .ok_or(PairError::Refused)?;
166        let tag = confirmation.tag(true, false);
167        self.password.zeroize();
168        self.pending = Some((confirmation, peer));
169        Ok(PairAnswer {
170            error: PairError::None.into(),
171            share: share.to_vec(),
172            tag,
173            info: Some(info),
174        })
175    }
176
177    /// Verify the requester proof, returning a peer to persist before `done()`.
178    ///
179    /// # Errors
180    /// Every failure returns only `Refused` and permanently ends pairing mode.
181    #[allow(
182        clippy::needless_pass_by_value,
183        reason = "consume the one-use wire message at this state transition"
184    )]
185    pub fn confirm(&mut self, confirm: PairConfirm, now: Instant) -> Result<Paired, PairError> {
186        let pending = self.pending.take();
187        self.refuse();
188        let (confirmation, peer) = pending.ok_or(PairError::Refused)?;
189        if self.expired(now)
190            || confirm.error != i32::from(PairError::None)
191            || !confirmation.verify(&confirm.tag, false, false)
192        {
193            return Err(PairError::Refused);
194        }
195        Ok(Paired { peer, confirmation })
196    }
197
198    fn expired(&self, now: Instant) -> bool {
199        now.saturating_duration_since(self.opened_at) >= limits::PAIRING_CODE_DURATION
200    }
201}
202
203/// Requester exchange. No peer is returned until the storage MAC verifies.
204pub struct RequesterPairing {
205    secret: Zeroizing<curve25519_dalek::scalar::Scalar>,
206    password: Zeroizing<curve25519_dalek::scalar::Scalar>,
207    pin: Pin,
208    start: PairStart,
209    answered: bool,
210    pending: Option<(Confirmation, Peer)>,
211}
212
213impl fmt::Debug for RequesterPairing {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        f.debug_struct("RequesterPairing").finish_non_exhaustive()
216    }
217}
218
219impl RequesterPairing {
220    pub fn new(
221        code: PairingCode,
222        qr: Option<[u8; 16]>,
223        pin: Pin,
224        info: PeerInfo,
225        rng: &mut (impl CryptoRng + RngCore),
226    ) -> Self {
227        let qr = Zeroizing::new(qr);
228        let password = Zeroizing::new(crypto::password(&code, qr.as_ref()));
229        drop(code);
230        let secret = Zeroizing::new(crypto::random_scalar(rng));
231        let start = PairStart {
232            share: crypto::share(&secret, &password, true).to_vec(),
233            info: Some(info),
234            qr: qr.is_some(),
235        };
236        Self {
237            secret,
238            password,
239            pin,
240            start,
241            answered: false,
242            pending: None,
243        }
244    }
245
246    /// The start message for this exchange; send it only once.
247    #[must_use]
248    pub fn start(&self) -> PairStart {
249        self.start.clone()
250    }
251
252    /// Verify the host proof and produce the requester proof.
253    ///
254    /// # Errors
255    /// Every failure returns only `Refused`; another answer cannot be tried.
256    pub fn answer(
257        &mut self,
258        answer: PairAnswer,
259        host_pin: Pin,
260        ctx: [u8; 32],
261    ) -> Result<PairConfirm, PairError> {
262        let pending = if self.answered {
263            Err(PairError::Refused)
264        } else {
265            self.verify_answer(answer, host_pin, ctx)
266        };
267        self.answered = true;
268        self.secret.zeroize();
269        self.password.zeroize();
270        self.pending = None;
271        let (confirmation, peer) = pending?;
272        let tag = confirmation.tag(false, false);
273        self.pending = Some((confirmation, peer));
274        Ok(PairConfirm {
275            tag,
276            error: PairError::None.into(),
277        })
278    }
279
280    fn verify_answer(
281        &self,
282        answer: PairAnswer,
283        pin: Pin,
284        ctx: [u8; 32],
285    ) -> Result<(Confirmation, Peer), PairError> {
286        if answer.error != i32::from(PairError::None) || answer.tag.len() != TAG_LENGTH {
287            return Err(PairError::Refused);
288        }
289        let point = crypto::decode_share(&answer.share).ok_or(PairError::Refused)?;
290        let peer = Peer {
291            pin,
292            info: answer.info.ok_or(PairError::Refused)?,
293        };
294        // RFC 9382 section 3.3: K = h*x*(pB - w*N).
295        let shared =
296            (*self.secret * (point - *self.password * crypto::mask(false))).mul_by_cofactor();
297        let confirmation = Confirmation::new(
298            (self.pin.as_bytes(), pin.as_bytes()),
299            (&self.start.share, &answer.share),
300            shared,
301            &self.password,
302            &ctx,
303        )
304        .ok_or(PairError::Refused)?;
305        if !confirmation.verify(&answer.tag, true, false) {
306            return Err(PairError::Refused);
307        }
308        Ok((confirmation, peer))
309    }
310
311    /// Return the authenticated host only after verifying its storage proof.
312    ///
313    /// # Errors
314    /// Returns only `Refused` on an out-of-order, rejected or invalid done message.
315    #[allow(
316        clippy::needless_pass_by_value,
317        reason = "consume the one-use wire message at this state transition"
318    )]
319    pub fn done(self, done: PairDone) -> Result<Peer, PairError> {
320        let (confirmation, peer) = self.pending.ok_or(PairError::Refused)?;
321        if done.error != i32::from(PairError::None) || !confirmation.verify(&done.tag, true, true) {
322            return Err(PairError::Refused);
323        }
324        Ok(peer)
325    }
326}
327
328/// Rolling one-minute source limiter. Capacity bounds memory even under source churn.
329#[derive(Debug)]
330pub struct AttemptLimiter<K> {
331    sources: HashMap<K, VecDeque<Instant>>,
332    max_sources: usize,
333}
334
335impl<K: Eq + Hash> AttemptLimiter<K> {
336    #[must_use]
337    pub fn new(max_sources: usize) -> Self {
338        Self {
339            sources: HashMap::new(),
340            max_sources,
341        }
342    }
343
344    /// Count an allowed attempt; reject new sources if every bounded slot is live.
345    pub fn allow(&mut self, source: K, now: Instant) -> bool {
346        self.sources.retain(|_, attempts| {
347            while attempts.front().is_some_and(|&at| {
348                now.saturating_duration_since(at) >= limits::PAIRING_ATTEMPT_WINDOW
349            }) {
350                attempts.pop_front();
351            }
352            !attempts.is_empty()
353        });
354        if !self.sources.contains_key(&source) && self.sources.len() >= self.max_sources {
355            return false;
356        }
357        let attempts = self.sources.entry(source).or_default();
358        if attempts.len() >= limits::PAIRING_ATTEMPTS_PER_MINUTE_PER_SOURCE {
359            return false;
360        }
361        attempts.push_back(now);
362        true
363    }
364}