1use 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#[derive(Debug)]
31pub struct Peer {
32 pub pin: Pin,
33 pub info: PeerInfo,
34}
35
36pub struct Paired {
38 pub peer: Peer,
39 confirmation: Confirmation,
40}
41
42impl Paired {
43 #[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
59pub 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 pub fn refuse(&mut self) {
95 self.closed = true;
96 self.pending = None;
97 self.password.zeroize();
98 }
99
100 #[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 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 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 #[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
203pub 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 #[must_use]
248 pub fn start(&self) -> PairStart {
249 self.start.clone()
250 }
251
252 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 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 #[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#[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 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}