use std::{
collections::{HashMap, VecDeque},
fmt,
hash::Hash,
time::Instant,
};
use a7n_proto::{
Pin, limits,
v1::{PairAnswer, PairConfirm, PairDone, PairError, PairStart, PeerInfo},
};
use rand_core::{CryptoRng, RngCore};
use zeroize::{Zeroize, Zeroizing};
mod code;
mod crypto;
pub use code::{InvalidPairingCode, PairingCode};
#[cfg(test)]
mod tests;
use crypto::{Confirmation, TAG_LENGTH};
#[derive(Debug)]
pub struct Peer {
pub pin: Pin,
pub info: PeerInfo,
}
pub struct Paired {
pub peer: Peer,
confirmation: Confirmation,
}
impl Paired {
#[must_use]
pub fn done(self) -> PairDone {
PairDone {
error: PairError::None.into(),
tag: self.confirmation.tag(true, true),
}
}
}
impl fmt::Debug for Paired {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Paired").finish_non_exhaustive()
}
}
pub struct HostPairing {
password: Zeroizing<curve25519_dalek::scalar::Scalar>,
qr: bool,
pin: Pin,
opened_at: Instant,
attempts: usize,
pending: Option<(Confirmation, Peer)>,
closed: bool,
}
impl fmt::Debug for HostPairing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HostPairing").finish_non_exhaustive()
}
}
impl HostPairing {
#[must_use]
pub fn new(code: PairingCode, qr: Option<[u8; 16]>, pin: Pin, opened_at: Instant) -> Self {
let qr = Zeroizing::new(qr);
let password = Zeroizing::new(crypto::password(&code, qr.as_ref()));
drop(code);
Self {
password,
qr: qr.is_some(),
pin,
opened_at,
attempts: 0,
pending: None,
closed: false,
}
}
pub fn refuse(&mut self) {
self.closed = true;
self.pending = None;
self.password.zeroize();
}
#[allow(
clippy::too_many_arguments,
reason = "the I/O shell supplies explicit identity, binding, time and randomness"
)]
pub fn start(
&mut self,
start: PairStart,
requester_pin: Pin,
ctx: [u8; 32],
info: PeerInfo,
now: Instant,
rng: &mut (impl CryptoRng + RngCore),
) -> Result<PairAnswer, PairError> {
if self.closed {
self.refuse();
return Err(PairError::NotPairing);
}
if self.attempts >= limits::PAIRING_CODE_GUESSES
|| self.pending.is_some()
|| self.expired(now)
{
self.refuse();
return Err(PairError::Refused);
}
self.attempts += 1;
self.closed = true;
let result = self.answer(start, requester_pin, ctx, info, rng);
if result.is_err() {
self.refuse();
}
result
}
fn answer(
&mut self,
start: PairStart,
pin: Pin,
ctx: [u8; 32],
info: PeerInfo,
rng: &mut (impl CryptoRng + RngCore),
) -> Result<PairAnswer, PairError> {
let point = crypto::decode_share(&start.share).ok_or(PairError::Refused)?;
if start.qr != self.qr {
return Err(PairError::Refused);
}
let peer = Peer {
pin,
info: start.info.ok_or(PairError::Refused)?,
};
let secret = Zeroizing::new(crypto::random_scalar(rng));
let share = crypto::share(&secret, &self.password, false);
let shared = (*secret * (point - *self.password * crypto::mask(true))).mul_by_cofactor();
let confirmation = Confirmation::new(
(pin.as_bytes(), self.pin.as_bytes()),
(&start.share, &share),
shared,
&self.password,
&ctx,
)
.ok_or(PairError::Refused)?;
let tag = confirmation.tag(true, false);
self.password.zeroize();
self.pending = Some((confirmation, peer));
Ok(PairAnswer {
error: PairError::None.into(),
share: share.to_vec(),
tag,
info: Some(info),
})
}
#[allow(
clippy::needless_pass_by_value,
reason = "consume the one-use wire message at this state transition"
)]
pub fn confirm(&mut self, confirm: PairConfirm, now: Instant) -> Result<Paired, PairError> {
let pending = self.pending.take();
self.refuse();
let (confirmation, peer) = pending.ok_or(PairError::Refused)?;
if self.expired(now)
|| confirm.error != i32::from(PairError::None)
|| !confirmation.verify(&confirm.tag, false, false)
{
return Err(PairError::Refused);
}
Ok(Paired { peer, confirmation })
}
fn expired(&self, now: Instant) -> bool {
now.saturating_duration_since(self.opened_at) >= limits::PAIRING_CODE_DURATION
}
}
pub struct RequesterPairing {
secret: Zeroizing<curve25519_dalek::scalar::Scalar>,
password: Zeroizing<curve25519_dalek::scalar::Scalar>,
pin: Pin,
start: PairStart,
answered: bool,
pending: Option<(Confirmation, Peer)>,
}
impl fmt::Debug for RequesterPairing {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RequesterPairing").finish_non_exhaustive()
}
}
impl RequesterPairing {
pub fn new(
code: PairingCode,
qr: Option<[u8; 16]>,
pin: Pin,
info: PeerInfo,
rng: &mut (impl CryptoRng + RngCore),
) -> Self {
let qr = Zeroizing::new(qr);
let password = Zeroizing::new(crypto::password(&code, qr.as_ref()));
drop(code);
let secret = Zeroizing::new(crypto::random_scalar(rng));
let start = PairStart {
share: crypto::share(&secret, &password, true).to_vec(),
info: Some(info),
qr: qr.is_some(),
};
Self {
secret,
password,
pin,
start,
answered: false,
pending: None,
}
}
#[must_use]
pub fn start(&self) -> PairStart {
self.start.clone()
}
pub fn answer(
&mut self,
answer: PairAnswer,
host_pin: Pin,
ctx: [u8; 32],
) -> Result<PairConfirm, PairError> {
let pending = if self.answered {
Err(PairError::Refused)
} else {
self.verify_answer(answer, host_pin, ctx)
};
self.answered = true;
self.secret.zeroize();
self.password.zeroize();
self.pending = None;
let (confirmation, peer) = pending?;
let tag = confirmation.tag(false, false);
self.pending = Some((confirmation, peer));
Ok(PairConfirm {
tag,
error: PairError::None.into(),
})
}
fn verify_answer(
&self,
answer: PairAnswer,
pin: Pin,
ctx: [u8; 32],
) -> Result<(Confirmation, Peer), PairError> {
if answer.error != i32::from(PairError::None) || answer.tag.len() != TAG_LENGTH {
return Err(PairError::Refused);
}
let point = crypto::decode_share(&answer.share).ok_or(PairError::Refused)?;
let peer = Peer {
pin,
info: answer.info.ok_or(PairError::Refused)?,
};
let shared =
(*self.secret * (point - *self.password * crypto::mask(false))).mul_by_cofactor();
let confirmation = Confirmation::new(
(self.pin.as_bytes(), pin.as_bytes()),
(&self.start.share, &answer.share),
shared,
&self.password,
&ctx,
)
.ok_or(PairError::Refused)?;
if !confirmation.verify(&answer.tag, true, false) {
return Err(PairError::Refused);
}
Ok((confirmation, peer))
}
#[allow(
clippy::needless_pass_by_value,
reason = "consume the one-use wire message at this state transition"
)]
pub fn done(self, done: PairDone) -> Result<Peer, PairError> {
let (confirmation, peer) = self.pending.ok_or(PairError::Refused)?;
if done.error != i32::from(PairError::None) || !confirmation.verify(&done.tag, true, true) {
return Err(PairError::Refused);
}
Ok(peer)
}
}
#[derive(Debug)]
pub struct AttemptLimiter<K> {
sources: HashMap<K, VecDeque<Instant>>,
max_sources: usize,
}
impl<K: Eq + Hash> AttemptLimiter<K> {
#[must_use]
pub fn new(max_sources: usize) -> Self {
Self {
sources: HashMap::new(),
max_sources,
}
}
pub fn allow(&mut self, source: K, now: Instant) -> bool {
self.sources.retain(|_, attempts| {
while attempts.front().is_some_and(|&at| {
now.saturating_duration_since(at) >= limits::PAIRING_ATTEMPT_WINDOW
}) {
attempts.pop_front();
}
!attempts.is_empty()
});
if !self.sources.contains_key(&source) && self.sources.len() >= self.max_sources {
return false;
}
let attempts = self.sources.entry(source).or_default();
if attempts.len() >= limits::PAIRING_ATTEMPTS_PER_MINUTE_PER_SOURCE {
return false;
}
attempts.push_back(now);
true
}
}