Skip to main content

qualia2fa_core/
pairing.rs

1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! Device-login pairing: hand the master key to a new device.
17//!
18//! # The problem it solves
19//!
20//! This application has no central server and no account password. "Logging in" means: a device
21//! that **already holds the master key** (the **initiator** A) hands the master key to a new
22//! device (the **joiner** B). Once B holds the master key, both devices can open the same
23//! entries.
24//!
25//! # Threat model
26//!
27//! Attackers that must be resisted:
28//!
29//! 1. **Sees only a photo of the QR code** (shot through a window, or an old screenshot).
30//! 2. **Sees the QR code and can modify it** (the user is talked into scanning a replaced code).
31//! 3. **Can read, write, replay and drop any message on the channel between A and B** (man in
32//!    the middle).
33//!
34//! Attackers that **need not** be resisted: one that already controls A or B. That device
35//! already holds the master key, so pairing can offer no extra protection.
36//!
37//! # Protocol
38//!
39//! ```text
40//!   A (signed in, holds master key M)       B (new device, no M)
41//!       │                                          │
42//!       │ ① generate an ephemeral key pair (a, A_pub) and a random nonce_A
43//!       │    QR code = { A_pub, nonce_A }          │
44//!       │ ───── out-of-band scan (QR code) ────►   │
45//!       │                                          │ ② generate (b, B_pub)
46//!       │                                          │    S = ECDH(b, A_pub)
47//!       │                                          │    derive the session keys from
48//!       │                                          │    S, A_pub, B_pub,
49//!       │                                          │    nonce_A, nonce_B
50//!       │ ◄── Msg1 = AEAD(K_ba, B identity pub) ─  │ ③ send its device identity
51//!       │         ‖ confirmation tag               │    public key to A
52//!       │ ④ check the confirmation tag: the peer   │
53//!       │    holds the session keys                │
54//!       │     Msg2 = AEAD(K_ab, M ‖ confirmation tag)
55//!       │ ──────────────────────────────────────►  │ ⑤ recover M, check the confirmation tag
56//!       │                                          │
57//! ```
58//!
59//! **The master key appears only in `Msg2`, and only as ciphertext.** The joiner has no M at
60//! all before receiving it.
61//!
62//! `A_pub` and `nonce_A` are out-of-band authenticated inputs: the QR code is the out-of-band
63//! channel (A's screen displays it, B's camera reads it, and B's user **looks at the code being
64//! scanned**). Key agreement over the channel alone cannot resist a man in the middle, and with no
65//! account password there is no low-entropy shared secret for a PAKE to use.
66//!
67//! ## What happens if an attacker modifies the QR code
68//!
69//! The attacker replaces `A_pub` with its own `M_pub`. B then agrees on `S'` with M, encrypts
70//! `Msg1` under `S'` and sends it. **M can decrypt `Msg1`, but it holds only B's device identity
71//! public key** — M does not obtain the master key. If M then impersonates A and returns a
72//! "`Msg2`" to B, that message has to be encrypted under `K_ab'` derived from `S'`, which M can
73//! indeed do, but M **does not hold the master key** and so cannot put it into `Msg2`.
74//!
75//! The attack therefore gains nothing: B either receives a message from which no master key can
76//! be recovered (M's forgery) or the pairing times out. **Modifying the QR code cannot yield the
77//! master key**, which is exactly what out-of-band authentication is meant to guarantee.
78//!
79//! The only real cost is denial of service: an attacker can **make pairing fail**. No protocol
80//! layer can prevent that, since anyone able to modify the channel can do it, and the user sees
81//! "pairing failed" and retries.
82//!
83//! # Protocol invariants
84//!
85//! 1. **The master key flows only from A to B, only inside `Msg2`, and only as ciphertext.**
86//! 2. **The two directions use two different keys**, derived from the same ECDH secret and
87//!    separated by different HKDF info strings.
88//! 3. **Each session is used once.** [`PendingSession::accept`] takes `self`, so the type system
89//!    makes a second use impossible; the ephemeral private key is erased on `Drop`.
90//! 4. **Mutual confirmation.** The tag in `Msg1` proves B holds the session keys; the tag in
91//!    `Msg2` proves A holds them and **really decrypted `Msg1`**, which is why that confirmation
92//!    tag covers B's identity public key.
93//! 5. **Everything is authenticated.** The message kind and the whole transcript go into the AAD.
94//!
95//! # What this module does not do
96//!
97//! - **No transport.** How the QR code is displayed and how messages are delivered is up to the
98//!   caller. This module only produces and consumes bytes.
99//! - **No persistence.** Where the master key is stored (a platform hardware keystore) is up to
100//!   the caller.
101//! - **No clock.** The core library does not read a clock. A pairing payload expires because
102//!   closing the pairing screen drops the [`PendingSession`], not because of a timestamp: a
103//!   timestamp would need a clock and would still not stop replaying a payload that has not
104//!   expired.
105
106use crate::vault::{MASTER_KEY_LEN, MasterKey, NONCE_LEN as AEAD_NONCE_LEN, master_key_to_bytes};
107use crate::{Error, ExposeSecret, Result, SecretBytes};
108use chacha20poly1305::aead::{AeadInOut, KeyInit};
109use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
110use hkdf::Hkdf;
111use hmac::{Hmac, Mac};
112use p256::PublicKey;
113use p256::ecdh::EphemeralSecret;
114use p256::elliptic_curve::Generate;
115use p256::elliptic_curve::sec1::ToSec1Point as _;
116use secrecy::zeroize::Zeroize;
117use sha2::{Digest, Sha256};
118
119/// Pairing protocol version, encoded into the QR code and both messages.
120///
121/// The version **must** take part in key derivation (see `transcript_hash`): otherwise an
122/// attacker could answer a session of a newer version with an `Msg1` of an older one, and both
123/// sides would still compute the same transcript.
124pub const PROTOCOL_VERSION: u8 = 1;
125
126/// Byte length of a P-256 SEC1 uncompressed public key.
127pub const PUBLIC_KEY_LEN: usize = 65;
128
129/// Byte length of a pairing random nonce.
130///
131/// 256-bit. It is what keeps **one QR code from being used twice**: A generates a fresh
132/// ephemeral key and nonce every time the pairing screen is opened, so an `Msg1` captured in a
133/// previous round cannot be decrypted in this one.
134pub const NONCE_LEN: usize = 32;
135
136/// Byte length of the confirmation tag.
137///
138/// 256-bit. The tag only proves "I hold the session keys too" and carries no long-term
139/// authentication, so 128-bit would suffice; the full HMAC-SHA256 output is kept because that is
140/// its natural length and truncating it would only add a rule to explain.
141pub const CONFIRMATION_LEN: usize = 32;
142
143/// Byte length of the session identifier.
144pub const SESSION_ID_LEN: usize = 32;
145
146/// Byte length of the AEAD authentication tag (the same as in the vault format).
147pub const TAG_LEN: usize = 16;
148
149/// Byte length of the QR payload: version + ephemeral public key + nonce + capability flag.
150pub const QR_PAYLOAD_LEN: usize = 1 + PUBLIC_KEY_LEN + NONCE_LEN + 1;
151
152/// Byte length of the `Msg1` plaintext header: B's nonce + B's ephemeral public key.
153///
154/// **Plaintext, because it cannot be ciphertext.** The initiator needs B's ephemeral public key
155/// to compute the ECDH shared secret and B's nonce to compute the transcript, and both are
156/// prerequisites for decrypting the rest. Putting them inside the ciphertext would be a circular
157/// dependency.
158///
159/// Sending them in the clear is not a weakness: together they amount to the joiner's ephemeral
160/// public key, a single public value. Changing them only makes the two sides compute different
161/// transcripts, so decryption **necessarily fails**.
162pub const MSG1_HEADER_LEN: usize = NONCE_LEN + PUBLIC_KEY_LEN;
163
164/// Plaintext length of the encrypted part of `Msg1`: B's **device identity** public key + the
165/// confirmation tag.
166///
167/// The device identity sits inside the ciphertext rather than in the plaintext header because it
168/// says which device joined; that information belongs to the two pairing parties and must not
169/// reach anyone who can read the pairing traffic. Only a holder of the session keys — the
170/// initiator — can read it.
171pub const MSG1_BODY_PLAINTEXT_LEN: usize = PUBLIC_KEY_LEN + CONFIRMATION_LEN;
172
173/// Full byte length of `Msg1`: plaintext header + ciphertext body, AEAD tag included.
174pub const MSG1_LEN: usize = MSG1_HEADER_LEN + MSG1_BODY_PLAINTEXT_LEN + TAG_LEN;
175
176/// Plaintext byte length of `Msg2`: master key + confirmation tag.
177pub const MSG2_PLAINTEXT_LEN: usize = MASTER_KEY_LEN + CONFIRMATION_LEN;
178
179/// Byte length of the `Msg2` ciphertext, AEAD tag included.
180pub const MSG2_LEN: usize = MSG2_PLAINTEXT_LEN + TAG_LEN;
181
182/// The only capability flag in the QR code: this protocol carries the joiner's device identity
183/// public key.
184///
185/// Always 1. It is written into the wire format instead of being omitted so that, when
186/// capabilities are added later, an old version can reject the payload with an **explicit** error
187/// rather than reading an unknown byte as a known meaning.
188const CAPABILITY_DEVICE_IDENTITY: u8 = 0x01;
189
190/// HKDF domain-separation string: joiner → initiator direction.
191const HKDF_INFO_JOINER_TO_INITIATOR: &[u8] = b"Qualia2FA pairing v1 joiner-to-initiator";
192
193/// HKDF domain-separation string: initiator → joiner direction.
194const HKDF_INFO_INITIATOR_TO_JOINER: &[u8] = b"Qualia2FA pairing v1 initiator-to-joiner";
195
196/// Domain-separation string for the confirmation tag.
197const CONFIRMATION_LABEL: &[u8] = b"Qualia2FA pairing v1 confirmation";
198
199/// Domain-separation string for the derived AEAD nonce.
200const NONCE_LABEL: &[u8] = b"Qualia2FA pairing v1 aead nonce";
201
202/// The initiator's ephemeral public key and nonce, published through the QR code.
203///
204/// # Lifetime
205///
206/// Produced by [`begin`] and used by the joiner to derive the session keys. It is **not** a
207/// secret (it is displayed on screen) but it is **short-lived**: once [`PendingSession::accept`]
208/// has been called, or the user closes the pairing screen and drops the [`PendingSession`], the
209/// matching private key no longer exists.
210#[derive(Clone, PartialEq, Eq)]
211pub struct QrPayload {
212    /// The initiator's ephemeral ECDH public key (SEC1 uncompressed point).
213    public_key: [u8; PUBLIC_KEY_LEN],
214    /// The initiator's random nonce.
215    nonce: [u8; NONCE_LEN],
216    /// Capability flags.
217    capabilities: u8,
218}
219
220impl QrPayload {
221    /// SEC1 encoding of the initiator's ephemeral public key.
222    #[must_use]
223    pub const fn public_key(&self) -> &[u8; PUBLIC_KEY_LEN] {
224        &self.public_key
225    }
226
227    /// Random nonce. Not a secret, but **unique**: the same nonce is never used twice.
228    #[must_use]
229    pub const fn nonce(&self) -> &[u8; NONCE_LEN] {
230        &self.nonce
231    }
232
233    /// Capability flags.
234    #[must_use]
235    pub const fn capabilities(&self) -> u8 {
236        self.capabilities
237    }
238
239    /// Encode into the byte string that goes into the QR code.
240    #[must_use]
241    pub fn encode(&self) -> Vec<u8> {
242        let mut out = Vec::with_capacity(QR_PAYLOAD_LEN);
243        out.push(PROTOCOL_VERSION);
244        out.extend_from_slice(&self.public_key);
245        out.extend_from_slice(&self.nonce);
246        out.push(self.capabilities);
247        out
248    }
249
250    /// Reconstruct from scanned bytes.
251    ///
252    /// # Errors
253    ///
254    /// - wrong length, or a required capability bit missing: [`Error::PairingPayloadMalformed`]
255    /// - unknown version: [`Error::PairingUnsupportedVersion`]
256    /// - the public key is not a valid curve point: [`Error::DevicePublicKeyInvalid`]
257    ///
258    /// **The curve-membership check on the public key is a security boundary, not a format
259    /// check**: handing ECDH a point that is off the curve but inside a small subgroup carries a
260    /// real key-recovery risk.
261    pub fn decode(bytes: &[u8]) -> Result<Self> {
262        if bytes.len() != QR_PAYLOAD_LEN {
263            return Err(Error::PairingPayloadMalformed {
264                reason: "the length is not 1 + 65 + 32 + 1 bytes",
265            });
266        }
267        if bytes[0] != PROTOCOL_VERSION {
268            return Err(Error::PairingUnsupportedVersion { version: bytes[0] });
269        }
270        let mut public_key = [0_u8; PUBLIC_KEY_LEN];
271        public_key.copy_from_slice(&bytes[1..1 + PUBLIC_KEY_LEN]);
272        let mut nonce = [0_u8; NONCE_LEN];
273        nonce.copy_from_slice(&bytes[1 + PUBLIC_KEY_LEN..1 + PUBLIC_KEY_LEN + NONCE_LEN]);
274        let capabilities = bytes[QR_PAYLOAD_LEN - 1];
275
276        PublicKey::from_sec1_bytes(&public_key).map_err(|_| Error::DevicePublicKeyInvalid {
277            reason: "the ephemeral public key in the QR code is not a valid P-256 SEC1 point",
278        })?;
279
280        if capabilities & CAPABILITY_DEVICE_IDENTITY == 0 {
281            return Err(Error::PairingPayloadMalformed {
282                reason: "the QR code is missing a required capability bit",
283            });
284        }
285
286        Ok(Self {
287            public_key,
288            nonce,
289            capabilities,
290        })
291    }
292
293    /// Encode as a Base32 string, convenient for the QR code's text field.
294    ///
295    /// Base32 rather than hexadecimal: QR capacity is the tightest constraint here, and Base32
296    /// costs only 1.6 characters per byte against hexadecimal's 2.
297    #[must_use]
298    pub fn to_base32(&self) -> String {
299        crate::base32::encode(&self.encode())
300    }
301
302    /// Reconstruct from a Base32 string.
303    ///
304    /// # Errors
305    ///
306    /// A Base32 decoding failure returns a Base32 error; otherwise as in [`QrPayload::decode`].
307    pub fn from_base32(text: &str) -> Result<Self> {
308        let bytes = crate::base32::decode(text)?;
309        Self::decode(bytes.expose_secret())
310    }
311}
312
313impl core::fmt::Debug for QrPayload {
314    /// Prints the public key and the capability bits, **not the nonce**.
315    ///
316    /// The nonce is not a secret, but it would let a log correlate which pairing rounds belong
317    /// together. Pairing is a sensitive operation, so leaving no correlatable identifier in logs
318    /// is preferable.
319    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
320        f.debug_struct("QrPayload")
321            .field("version", &PROTOCOL_VERSION)
322            .field("public_key", &"<65 bytes>")
323            .field("nonce", &"<redacted>")
324            .field("capabilities", &format_args!("0x{:02X}", self.capabilities))
325            .finish()
326    }
327}
328
329/// Initiator A's pending session: it holds the ephemeral private key and waits for the joiner's
330/// `Msg1`.
331///
332/// # Single use, guaranteed by the type system
333///
334/// [`PendingSession::accept`] takes `self` rather than `&self`, so **the same session cannot be
335/// accepted twice**. The constraint does not rely on the caller's discipline:
336///
337/// - replaying the same `Msg1` a second time does not compile, because there is no session left
338///   to pass it to;
339/// - reopening the pairing screen yields a **new** [`PendingSession`] with a different nonce and
340///   ephemeral public key, so an `Msg1` captured in a previous round cannot be decrypted.
341///
342/// The ephemeral private key is erased on `Drop` by `p256` (`EphemeralSecret` implements
343/// `ZeroizeOnDrop`).
344pub struct PendingSession {
345    /// This side's ephemeral private key.
346    secret: EphemeralSecret,
347    /// The payload matching the QR code contents.
348    payload: QrPayload,
349}
350
351impl PendingSession {
352    /// What the QR code should display.
353    #[must_use]
354    pub const fn payload(&self) -> &QrPayload {
355        &self.payload
356    }
357
358    /// Accept the joiner's `Msg1`, confirm the peer's identity, and produce the `Msg2` that
359    /// carries the master key.
360    ///
361    /// # Parameters
362    ///
363    /// - `msg1`: the joiner's complete `Msg1` ciphertext (the return value of
364    ///   [`JoinerSession::request`]).
365    /// - `master`: the **initiator's own** master key. It goes into the receipt as ciphertext.
366    ///
367    /// # Returns
368    ///
369    /// [`Adoption`]: the joiner's device identity public key, the session identifier, and the
370    /// `Msg2` to send back to the joiner.
371    ///
372    /// # Errors
373    ///
374    /// Every authentication failure returns [`Error::PairingAuthenticationFailed`], **without**
375    /// distinguishing "the ciphertext was tampered with", "the message came from another session"
376    /// and "the joiner used the wrong public key". Telling them apart would help an attacker and
377    /// gives the user nothing to act on: the answer to all three is "this pairing failed, scan
378    /// again".
379    ///
380    /// The whole ciphertext body — the trailing AEAD tag included — is handed to
381    /// `decrypt_in_place`, which strips the last `TAG_LEN` bytes itself. After decryption the
382    /// joiner's device identity public key is checked for curve membership before it is reported
383    /// in [`Adoption`]; a point off the curve would otherwise make later signature verification
384    /// fail for reasons far from the cause.
385    pub fn accept(self, msg1: &[u8], master: &MasterKey) -> Result<Adoption> {
386        if msg1.len() != MSG1_LEN {
387            return Err(Error::PairingAuthenticationFailed);
388        }
389        let mut joiner_nonce = [0_u8; NONCE_LEN];
390        joiner_nonce.copy_from_slice(&msg1[..NONCE_LEN]);
391        let mut joiner_ephemeral = [0_u8; PUBLIC_KEY_LEN];
392        joiner_ephemeral.copy_from_slice(&msg1[NONCE_LEN..MSG1_HEADER_LEN]);
393        let joiner_ephemeral = PublicKey::from_sec1_bytes(&joiner_ephemeral).map_err(|_| {
394            Error::DevicePublicKeyInvalid {
395                reason: "the joiner's ephemeral public key is not a valid P-256 SEC1 point",
396            }
397        })?;
398
399        let transcript = transcript_hash(
400            &self.payload.public_key,
401            &self.payload.nonce,
402            &msg1[NONCE_LEN..MSG1_HEADER_LEN],
403            &joiner_nonce,
404        );
405        let shared = self.secret.diffie_hellman(&joiner_ephemeral);
406        let keys = SessionKeys::derive(shared.raw_secret_bytes(), &transcript);
407
408        let mut plaintext = msg1[MSG1_HEADER_LEN..].to_vec();
409        let aad = aad_for(&transcript, MessageKind::Request);
410        let cipher = XChaCha20Poly1305::new(&Key::from(keys.joiner_to_initiator));
411        cipher
412            .decrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut plaintext)
413            .map_err(|_| Error::PairingAuthenticationFailed)?;
414        debug_assert_eq!(plaintext.len(), MSG1_BODY_PLAINTEXT_LEN);
415
416        let mut joiner_identity = [0_u8; PUBLIC_KEY_LEN];
417        joiner_identity.copy_from_slice(&plaintext[..PUBLIC_KEY_LEN]);
418        let expected = confirmation_tag(
419            &keys.joiner_to_initiator,
420            &transcript,
421            MessageKind::Request,
422            &joiner_identity,
423        );
424        if !constant_time_eq(&plaintext[PUBLIC_KEY_LEN..], &expected) {
425            return Err(Error::PairingAuthenticationFailed);
426        }
427        PublicKey::from_sec1_bytes(&joiner_identity).map_err(|_| {
428            Error::DevicePublicKeyInvalid {
429                reason: "the joiner's device identity public key is not a valid P-256 SEC1 point",
430            }
431        })?;
432
433        let mut reply = Vec::with_capacity(MSG2_PLAINTEXT_LEN);
434        reply.extend_from_slice(master.expose());
435        reply.extend_from_slice(&confirmation_tag(
436            &keys.initiator_to_joiner,
437            &transcript,
438            MessageKind::Accept,
439            &joiner_identity,
440        ));
441        let aad = aad_for(&transcript, MessageKind::Accept);
442        let reply_cipher = XChaCha20Poly1305::new(&Key::from(keys.initiator_to_joiner));
443        reply_cipher
444            .encrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut reply)
445            .map_err(|_| Error::PairingAuthenticationFailed)?;
446
447        Ok(Adoption {
448            session_id: transcript,
449            joiner_identity,
450            reply,
451        })
452    }
453}
454
455impl core::fmt::Debug for PendingSession {
456    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
457        f.debug_struct("PendingSession")
458            .field("payload", &self.payload)
459            .field("secret", &"<redacted>")
460            .finish()
461    }
462}
463
464/// The result of [`PendingSession::accept`].
465pub struct Adoption {
466    /// Session identifier (both sides agree on it; usable for log correlation).
467    session_id: [u8; SESSION_ID_LEN],
468    /// The joiner's device identity public key (SEC1). This is how the initiator learns which device joined.
469    joiner_identity: [u8; PUBLIC_KEY_LEN],
470    /// The `Msg2` ciphertext to send back to the joiner.
471    reply: Vec<u8>,
472}
473
474impl Adoption {
475    /// Session identifier. Both sides compute the same value, so the two ends' logs can be matched up.
476    #[must_use]
477    pub const fn session_id(&self) -> &[u8; SESSION_ID_LEN] {
478        &self.session_id
479    }
480
481    /// The joiner's device identity public key.
482    ///
483    /// The caller should build a [`qualia_dik::DevicePublicKey`] from it and **record it**: it is
484    /// the only basis for "which device this device trusts". Its authenticity comes from the
485    /// pairing channel (only a holder of the session keys can deliver it here), but **after
486    /// pairing** it is only a recorded identity, and later authentication always goes through
487    /// signature verification.
488    #[must_use]
489    pub const fn joiner_identity(&self) -> &[u8; PUBLIC_KEY_LEN] {
490        &self.joiner_identity
491    }
492
493    /// The `Msg2` ciphertext to send back to the joiner.
494    #[must_use]
495    pub fn reply(&self) -> &[u8] {
496        &self.reply
497    }
498}
499
500impl core::fmt::Debug for Adoption {
501    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
502        f.debug_struct("Adoption")
503            .field("session_id", &&self.session_id[..8])
504            .field("joiner_identity", &"<65 bytes>")
505            .field("reply_len", &self.reply.len())
506            .finish()
507    }
508}
509
510/// Joiner B's session: the QR code has been scanned and it waits to receive the master key.
511///
512/// The call order is `join` → `request` → `open`. `request` borrows `&self` (the caller still needs
513/// the same session for `open` after sending `Msg1`); `open` takes `self` and consumes the session,
514/// erasing the ephemeral private key.
515pub struct JoinerSession {
516    /// This side's ephemeral private key.
517    secret: EphemeralSecret,
518    /// This side's generated ephemeral public key (SEC1).
519    public_key: [u8; PUBLIC_KEY_LEN],
520    /// This side's generated nonce. It travels in the `Msg1` plaintext and takes part in the transcript.
521    nonce: [u8; NONCE_LEN],
522    /// The payload parsed from the QR code.
523    payload: QrPayload,
524}
525
526impl JoinerSession {
527    /// SEC1 encoding of this side's ephemeral public key.
528    #[must_use]
529    pub const fn public_key(&self) -> &[u8; PUBLIC_KEY_LEN] {
530        &self.public_key
531    }
532
533    /// Produce `Msg1`: hand this side's device identity public key to the initiator.
534    ///
535    /// # Parameters
536    ///
537    /// - `device_identity`: SEC1 encoding of this side's device identity public key (provided by
538    ///   `qualia-dik`).
539    ///
540    /// # Returns
541    ///
542    /// The complete `Msg1` ciphertext, and this session's identifier.
543    ///
544    /// # This message carries no master key
545    ///
546    /// The joiner **does not have** the master key at this point; that is what it is here for.
547    /// `Msg1` carries only the joiner's identity public key and a confirmation tag. The master
548    /// key arrives in the opposite direction, in [`JoinerSession::open`].
549    ///
550    /// # Wire format
551    ///
552    /// `Msg1` is `header(plaintext) ‖ AEAD(identity public key ‖ confirmation tag)`. The header is
553    /// assembled separately because it must not enter the ciphertext (see [`MSG1_HEADER_LEN`]).
554    /// The device identity public key is checked for curve membership here, before any message is
555    /// produced, so an unusable key surfaces as a local error instead of as a reported pairing
556    /// failure on the other side.
557    ///
558    /// # Errors
559    ///
560    /// - device identity public key of the wrong length, or not a valid curve point:
561    ///   [`Error::DevicePublicKeyInvalid`]
562    /// - AEAD failure: [`Error::PairingAuthenticationFailed`]
563    pub fn request(&self, device_identity: &[u8]) -> Result<(Vec<u8>, [u8; SESSION_ID_LEN])> {
564        if device_identity.len() != PUBLIC_KEY_LEN {
565            return Err(Error::DevicePublicKeyInvalid {
566                reason: "the device identity public key is not 65 bytes long",
567            });
568        }
569        PublicKey::from_sec1_bytes(device_identity).map_err(|_| Error::DevicePublicKeyInvalid {
570            reason: "the device identity public key is not a valid P-256 SEC1 point",
571        })?;
572        let (transcript, keys) = self.derive()?;
573
574        let mut body = Vec::with_capacity(MSG1_BODY_PLAINTEXT_LEN);
575        body.extend_from_slice(device_identity);
576        body.extend_from_slice(&confirmation_tag(
577            &keys.joiner_to_initiator,
578            &transcript,
579            MessageKind::Request,
580            device_identity,
581        ));
582
583        let aad = aad_for(&transcript, MessageKind::Request);
584        let cipher = XChaCha20Poly1305::new(&Key::from(keys.joiner_to_initiator));
585        cipher
586            .encrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut body)
587            .map_err(|_| Error::PairingAuthenticationFailed)?;
588
589        let mut msg = Vec::with_capacity(MSG1_LEN);
590        msg.extend_from_slice(&self.nonce);
591        msg.extend_from_slice(&self.public_key);
592        msg.extend_from_slice(&body);
593
594        Ok((msg, transcript))
595    }
596
597    /// Receive the master key from the initiator's `Msg2`.
598    ///
599    /// The master key is handed to the caller only here, and `MasterKey::from_bytes` validates its
600    /// length, so a payload of the wrong length can never become a master key that looks usable.
601    /// The whole ciphertext — the trailing AEAD tag included — goes to `decrypt_in_place`, which
602    /// strips the last `TAG_LEN` bytes itself.
603    ///
604    /// # Errors
605    ///
606    /// Every authentication failure returns [`Error::PairingAuthenticationFailed`]: **wrong
607    /// length, wrong tag, tampered ciphertext and a message from another session** all map to that
608    /// one variant. A device identity public key of the wrong length returns
609    /// [`Error::DevicePublicKeyInvalid`].
610    pub fn open(self, msg2: &[u8], device_identity: &[u8]) -> Result<MasterKey> {
611        if msg2.len() != MSG2_LEN {
612            return Err(Error::PairingAuthenticationFailed);
613        }
614        if device_identity.len() != PUBLIC_KEY_LEN {
615            return Err(Error::DevicePublicKeyInvalid {
616                reason: "the device identity public key is not 65 bytes long",
617            });
618        }
619        let (transcript, keys) = self.derive()?;
620
621        let mut plaintext = msg2.to_vec();
622        let aad = aad_for(&transcript, MessageKind::Accept);
623        let cipher = XChaCha20Poly1305::new(&Key::from(keys.initiator_to_joiner));
624        cipher
625            .decrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut plaintext)
626            .map_err(|_| Error::PairingAuthenticationFailed)?;
627        debug_assert_eq!(plaintext.len(), MSG2_PLAINTEXT_LEN);
628
629        let expected = confirmation_tag(
630            &keys.initiator_to_joiner,
631            &transcript,
632            MessageKind::Accept,
633            device_identity,
634        );
635        if !constant_time_eq(&plaintext[MASTER_KEY_LEN..], &expected) {
636            return Err(Error::PairingAuthenticationFailed);
637        }
638
639        let mut raw = plaintext[..MASTER_KEY_LEN].to_vec();
640        let secret: SecretBytes = core::mem::take(&mut raw).into();
641        raw.zeroize();
642        MasterKey::from_bytes(secret)
643    }
644
645    /// Derive this side's session material; it must come out exactly as on the initiator.
646    fn derive(&self) -> Result<([u8; SESSION_ID_LEN], SessionKeys)> {
647        let initiator_public = PublicKey::from_sec1_bytes(&self.payload.public_key).map_err(
648            |_| Error::DevicePublicKeyInvalid {
649                reason: "the ephemeral public key in the QR code is not a valid P-256 SEC1 point",
650            },
651        )?;
652        let transcript = transcript_hash(
653            &self.payload.public_key,
654            &self.payload.nonce,
655            &self.public_key,
656            &self.nonce,
657        );
658        let shared = self.secret.diffie_hellman(&initiator_public);
659        Ok((
660            transcript,
661            SessionKeys::derive(shared.raw_secret_bytes(), &transcript),
662        ))
663    }
664}
665
666impl core::fmt::Debug for JoinerSession {
667    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
668        f.debug_struct("JoinerSession")
669            .field("payload", &self.payload)
670            .field("public_key", &"<65 bytes>")
671            .field("nonce", &"<redacted>")
672            .field("secret", &"<redacted>")
673            .finish()
674    }
675}
676
677/// Message kind; it takes part in building the AAD and the confirmation tag.
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum MessageKind {
680    /// Joiner → initiator.
681    Request,
682    /// Initiator → joiner.
683    Accept,
684}
685
686impl MessageKind {
687    /// The byte value taking part in the computations.
688    const fn as_byte(self) -> u8 {
689        match self {
690            Self::Request => 0x01,
691            Self::Accept => 0x02,
692        }
693    }
694}
695
696/// The session keys derived by one pairing.
697///
698/// Both directions are derived from the same ECDH secret but with different HKDF info strings. The
699/// AEAD nonce is deliberately identical in both directions (no counter state), which is not nonce
700/// reuse: the keys differ, so the two ciphertexts bear no derivable relation to each other.
701struct SessionKeys {
702    /// Joiner → initiator direction.
703    joiner_to_initiator: [u8; 32],
704    /// Initiator → joiner direction.
705    initiator_to_joiner: [u8; 32],
706    /// AEAD nonce (shared by both directions; safe because the keys differ).
707    nonce: [u8; AEAD_NONCE_LEN],
708}
709
710impl SessionKeys {
711    /// Derive from the ECDH shared secret and the session transcript.
712    ///
713    /// The transcript is the HKDF salt: it contains both sides' public keys and nonces, so the
714    /// derived keys are bound to **this** pairing, and changing any of its bytes makes the two
715    /// sides derive different keys, after which authentication necessarily fails. The AEAD nonce
716    /// is derived from the transcript as well, so the same transcript always yields the same nonce
717    /// (the consistency both sides need) and different sessions yield different nonces.
718    ///
719    /// `expand` can only fail for output lengths above `255 * HashLen`, which 32 bytes never
720    /// reach, and a failure is still not unwrapped, because no input may panic in this library. On
721    /// failure both keys are zeroized, so later authentication necessarily fails instead of
722    /// silently using a predictable key.
723    fn derive(shared: &[u8], transcript: &[u8; SESSION_ID_LEN]) -> Self {
724        let hkdf = Hkdf::<Sha256>::new(Some(transcript), shared);
725        let mut joiner_to_initiator = [0_u8; 32];
726        let mut initiator_to_joiner = [0_u8; 32];
727        let failed = hkdf
728            .expand(HKDF_INFO_JOINER_TO_INITIATOR, &mut joiner_to_initiator)
729            .is_err()
730            || hkdf
731                .expand(HKDF_INFO_INITIATOR_TO_JOINER, &mut initiator_to_joiner)
732                .is_err();
733        if failed {
734            joiner_to_initiator.zeroize();
735            initiator_to_joiner.zeroize();
736        }
737
738        let mut hasher = Sha256::new();
739        hasher.update(NONCE_LABEL);
740        hasher.update(transcript);
741        let digest = hasher.finalize();
742        let mut nonce = [0_u8; AEAD_NONCE_LEN];
743        nonce.copy_from_slice(&digest[..AEAD_NONCE_LEN]);
744
745        Self {
746            joiner_to_initiator,
747            initiator_to_joiner,
748            nonce,
749        }
750    }
751}
752
753impl Drop for SessionKeys {
754    fn drop(&mut self) {
755        self.joiner_to_initiator.zeroize();
756        self.initiator_to_joiner.zeroize();
757        self.nonce.zeroize();
758    }
759}
760
761/// Compute the SHA-256 of the session transcript.
762///
763/// Input: version + initiator ephemeral public key + initiator nonce + joiner ephemeral public
764/// key + joiner nonce.
765///
766/// The result doubles as the HKDF salt, part of the AAD and the session identifier, and it is
767/// assembled **only here**, in the order above: both directions call this one function, so the two
768/// sides cannot disagree.
769fn transcript_hash(
770    initiator_public_key: &[u8; PUBLIC_KEY_LEN],
771    initiator_nonce: &[u8; NONCE_LEN],
772    joiner_public_key: &[u8],
773    joiner_nonce: &[u8; NONCE_LEN],
774) -> [u8; SESSION_ID_LEN] {
775    let mut hasher = Sha256::new();
776    hasher.update([PROTOCOL_VERSION]);
777    hasher.update(initiator_public_key);
778    hasher.update(initiator_nonce);
779    hasher.update(joiner_public_key);
780    hasher.update(joiner_nonce);
781    let digest = hasher.finalize();
782    let mut out = [0_u8; SESSION_ID_LEN];
783    out.copy_from_slice(&digest);
784    out
785}
786
787/// Build the AAD: session identifier + message kind.
788///
789/// The message kind must enter the AAD: otherwise an attacker could replay an `Msg2` ciphertext
790/// to the initiator as if it were an `Msg1` (or the other way round). That is already impossible
791/// because the two directions use **different keys**, but writing the kind into the AAD makes the
792/// property independent of that implementation detail.
793fn aad_for(transcript: &[u8; SESSION_ID_LEN], kind: MessageKind) -> [u8; SESSION_ID_LEN + 1] {
794    let mut aad = [0_u8; SESSION_ID_LEN + 1];
795    aad[..SESSION_ID_LEN].copy_from_slice(transcript);
796    aad[SESSION_ID_LEN] = kind.as_byte();
797    aad
798}
799
800/// Compute the confirmation tag: `HMAC-SHA256(key, label ‖ kind ‖ transcript ‖ peer_identity)`.
801///
802/// The peer identity is covered by the tag, so each message says "I see **this** peer and we share
803/// the session keys" rather than being a generic "OK" that transfers to another identity.
804///
805/// HMAC is used rather than comparing the session keys directly: comparing keys leaks whether two
806/// key segments are equal, while the HMAC output leaks nothing about the key. `new_from_slice`
807/// accepts a key of any length for HMAC-SHA256 and cannot fail; should it fail anyway, the result
808/// is an all-zero tag rather than a panic.
809fn confirmation_tag(
810    key: &[u8; 32],
811    transcript: &[u8; SESSION_ID_LEN],
812    kind: MessageKind,
813    peer_identity: &[u8],
814) -> [u8; CONFIRMATION_LEN] {
815    let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(key) else {
816        return [0_u8; CONFIRMATION_LEN];
817    };
818    mac.update(CONFIRMATION_LABEL);
819    mac.update(&[kind.as_byte()]);
820    mac.update(transcript);
821    mac.update(peer_identity);
822    let digest = mac.finalize().into_bytes();
823    let mut out = [0_u8; CONFIRMATION_LEN];
824    out.copy_from_slice(&digest);
825    out
826}
827
828/// Constant-time comparison of two byte strings.
829///
830/// Different lengths return `false` immediately: the length is not a secret (the protocol fixes
831/// it), so leaking it means nothing.
832fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
833    use subtle::ConstantTimeEq;
834    if a.len() != b.len() {
835        return false;
836    }
837    a.ct_eq(b).into()
838}
839
840/// Start one pairing as the initiator and return the payload to display as a QR code.
841///
842/// # Errors
843///
844/// Fails only when the OS random source is unavailable ([`Error::RandomnessUnavailable`]).
845///
846/// The payload goes into the QR code and the session keeps the ephemeral private key, so the two are
847/// produced and returned as a pair: a public key in the QR code without its matching private key
848/// makes every pairing fail.
849#[must_use = "the pairing session must be kept, or the public key in the QR code has no matching private key"]
850pub fn begin() -> Result<(QrPayload, PendingSession)> {
851    let secret = EphemeralSecret::generate();
852    let public_key = secret.public_key().to_sec1_point(false);
853    let mut public_key_bytes = [0_u8; PUBLIC_KEY_LEN];
854    public_key_bytes.copy_from_slice(public_key.as_bytes());
855
856    let mut nonce = [0_u8; NONCE_LEN];
857    crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
858
859    let payload = QrPayload {
860        public_key: public_key_bytes,
861        nonce,
862        capabilities: CAPABILITY_DEVICE_IDENTITY,
863    };
864    let session = PendingSession {
865        secret,
866        payload: payload.clone(),
867    };
868    Ok((payload, session))
869}
870
871/// Start one pairing as the joiner from a scanned QR code.
872///
873/// # Errors
874///
875/// - the public key in the QR code is not on the curve: [`Error::DevicePublicKeyInvalid`]
876/// - the OS random source is unavailable: [`Error::RandomnessUnavailable`]
877///
878/// The public key is validated again here even though [`QrPayload::decode`] already did so: a
879/// `JoinerSession` can also be created from a payload the caller assembled by hand, so it must not
880/// be assumed to come from `decode`.
881pub fn join(payload: &QrPayload) -> Result<JoinerSession> {
882    PublicKey::from_sec1_bytes(&payload.public_key).map_err(|_| Error::DevicePublicKeyInvalid {
883        reason: "the ephemeral public key in the QR code is not a valid P-256 SEC1 point",
884    })?;
885
886    let secret = EphemeralSecret::generate();
887    let public_key = secret.public_key().to_sec1_point(false);
888    let mut public_key_bytes = [0_u8; PUBLIC_KEY_LEN];
889    public_key_bytes.copy_from_slice(public_key.as_bytes());
890
891    let mut nonce = [0_u8; NONCE_LEN];
892    crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
893
894    Ok(JoinerSession {
895        secret,
896        public_key: public_key_bytes,
897        nonce,
898        payload: payload.clone(),
899    })
900}
901
902/// Wrap the master key into bytes that can be handed to a platform keystore.
903///
904/// A forward of [`master_key_to_bytes`], provided so that callers need not import both `vault` and
905/// `pairing`.
906#[must_use]
907pub fn master_key_for_storage(master: &MasterKey) -> SecretBytes {
908    master_key_to_bytes(master)
909}
910
911/// Rebuild the master key from bytes retrieved from a platform keystore.
912///
913/// # Errors
914///
915/// Returns [`Error::MasterKeyWrongLength`] when the length is not [`MASTER_KEY_LEN`].
916pub fn master_key_from_storage(bytes: SecretBytes) -> Result<MasterKey> {
917    MasterKey::from_bytes(bytes)
918}
919
920/// A record of already used pairing nonces, used to reject replays.
921///
922/// # What it protects against
923///
924/// The pairing protocol already makes each session single-use ([`PendingSession::accept`] takes
925/// `self`, and every [`begin`] generates a fresh ephemeral key and nonce). This record covers the
926/// layer above: a caller that persists nonces can still recognise "this QR code / this session has
927/// already been handled" **after a restart**, instead of accepting it again because in-memory
928/// state was lost.
929///
930/// # Bounded capacity
931///
932/// Pairing happens at the pace of manual user actions, so a few hundred entries are plenty. An
933/// **unbounded** record would be a resource an attacker could exhaust by starting pairings it
934/// never finishes. When the record is full the oldest entry is evicted; the session behind an
935/// evicted nonce ended long ago, so replaying it could not succeed anyway.
936#[derive(Debug, Clone, Default)]
937pub struct NonceLog {
938    /// Used nonces, in insertion order.
939    used: std::collections::VecDeque<[u8; NONCE_LEN]>,
940}
941
942impl NonceLog {
943    /// Number of nonces kept by default.
944    pub const DEFAULT_CAPACITY: usize = 256;
945
946    /// Create an empty record.
947    #[must_use]
948    pub fn new() -> Self {
949        Self::default()
950    }
951
952    /// Number of entries.
953    #[must_use]
954    pub fn len(&self) -> usize {
955        self.used.len()
956    }
957
958    /// Whether the record is empty.
959    #[must_use]
960    pub fn is_empty(&self) -> bool {
961        self.used.is_empty()
962    }
963
964    /// Whether this nonce has already been used.
965    #[must_use]
966    pub fn is_used(&self, nonce: &[u8; NONCE_LEN]) -> bool {
967        self.used.contains(nonce)
968    }
969
970    /// Record a nonce.
971    ///
972    /// `false` means this nonce **was recorded before** (that is, this is a replay) and the caller
973    /// should reject the operation; `true` means it was recorded.
974    pub fn record(&mut self, nonce: [u8; NONCE_LEN]) -> bool {
975        if self.is_used(&nonce) {
976            return false;
977        }
978        if self.used.len() >= Self::DEFAULT_CAPACITY {
979            self.used.pop_front();
980        }
981        self.used.push_back(nonce);
982        true
983    }
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989    use p256::SecretKey;
990
991    /// Build a **genuinely valid** device identity public key (a point on the curve, 65-byte
992    /// SEC1).
993    ///
994    /// Arbitrary bytes will not do: `from_sec1_bytes` performs the full curve-membership check, and
995    /// that is a security boundary. A key derived from a small scalar is enough here, since the
996    /// pairing protocol only moves the identity around as **opaque** bytes.
997    fn fake_identity() -> [u8; PUBLIC_KEY_LEN] {
998        public_key_of(&SecretKey::from_bytes(&[1_u8; 32].into()).expect("scalar 1 is valid"))
999    }
1000
1001    /// Another valid but different identity.
1002    fn other_identity() -> [u8; PUBLIC_KEY_LEN] {
1003        public_key_of(&SecretKey::from_bytes(&[2_u8; 32].into()).expect("scalar 2 is valid"))
1004    }
1005
1006    /// Take the SEC1 uncompressed public key belonging to a private key.
1007    fn public_key_of(secret: &SecretKey) -> [u8; PUBLIC_KEY_LEN] {
1008        let point = secret.public_key().to_sec1_point(false);
1009        let mut out = [0_u8; PUBLIC_KEY_LEN];
1010        out.copy_from_slice(point.as_bytes());
1011        out
1012    }
1013
1014    /// Run one complete pairing and return what each end sees.
1015    fn run_pairing() -> (
1016        MasterKey,
1017        MasterKey,
1018        [u8; SESSION_ID_LEN],
1019        [u8; SESSION_ID_LEN],
1020    ) {
1021        let initiator_master = MasterKey::generate().expect("randomness should be available");
1022        let identity = fake_identity();
1023
1024        let (payload, pending) = begin().expect("randomness should be available");
1025        let joiner = join(&payload).expect("randomness should be available");
1026        let (msg1, joiner_sid) = joiner.request(&identity).expect("Msg1 should be produced");
1027
1028        let adoption = pending
1029            .accept(&msg1, &initiator_master)
1030            .expect("Msg1 should authenticate");
1031        assert_eq!(
1032            adoption.joiner_identity(),
1033            &identity,
1034            "the initiator should see the joiner's device identity"
1035        );
1036
1037        let joiner_master = joiner
1038            .open(adoption.reply(), &identity)
1039            .expect("Msg2 should authenticate");
1040        (
1041            initiator_master,
1042            joiner_master,
1043            *adoption.session_id(),
1044            joiner_sid,
1045        )
1046    }
1047
1048    /// The end-to-end happy path: both sides end up with the **same** master key.
1049    #[test]
1050    fn happy_path_transfers_the_master_key() {
1051        let (a, b, sid_a, sid_b) = run_pairing();
1052        assert_eq!(
1053            a.expose(),
1054            b.expose(),
1055            "both sides must agree on the master key"
1056        );
1057        assert_eq!(
1058            sid_a, sid_b,
1059            "both sides must agree on the session identifier"
1060        );
1061    }
1062
1063    /// The QR payload must survive a Base32 round trip.
1064    #[test]
1065    fn qr_payload_roundtrips_through_base32() {
1066        let (payload, _pending) = begin().expect("randomness should be available");
1067        let text = payload.to_base32();
1068        let decoded = QrPayload::from_base32(&text).expect("it should decode");
1069        assert_eq!(decoded, payload);
1070    }
1071
1072    /// The QR byte layout is an external contract; pin it down.
1073    #[test]
1074    fn qr_payload_layout_is_stable() {
1075        let (payload, _pending) = begin().expect("randomness should be available");
1076        let bytes = payload.encode();
1077        assert_eq!(bytes.len(), QR_PAYLOAD_LEN);
1078        assert_eq!(bytes[0], PROTOCOL_VERSION);
1079        assert_eq!(&bytes[1..66], payload.public_key());
1080        assert_eq!(&bytes[66..98], payload.nonce());
1081        assert_eq!(bytes[98], CAPABILITY_DEVICE_IDENTITY);
1082    }
1083
1084    /// A QR payload of the wrong length must be rejected.
1085    #[test]
1086    fn qr_payload_rejects_wrong_length() {
1087        let (payload, _pending) = begin().expect("randomness should be available");
1088        let bytes = payload.encode();
1089        for cut in [0_usize, 1, QR_PAYLOAD_LEN - 1] {
1090            assert!(
1091                matches!(
1092                    QrPayload::decode(&bytes[..cut]),
1093                    Err(Error::PairingPayloadMalformed { .. })
1094                ),
1095                "length {cut} must be rejected"
1096            );
1097        }
1098        let mut longer = bytes.clone();
1099        longer.push(0);
1100        assert!(QrPayload::decode(&longer).is_err());
1101    }
1102
1103    /// An unknown version must be rejected **explicitly**, not read as this version.
1104    #[test]
1105    fn qr_payload_rejects_unknown_version() {
1106        let (payload, _pending) = begin().expect("randomness should be available");
1107        let mut bytes = payload.encode();
1108        bytes[0] = 0x7F;
1109        match QrPayload::decode(&bytes) {
1110            Err(Error::PairingUnsupportedVersion { version }) => assert_eq!(version, 0x7F),
1111            other => panic!(
1112                "an unsupported version should be reported, got is_ok={}",
1113                other.is_ok()
1114            ),
1115        }
1116    }
1117
1118    /// **A public key off the curve must be rejected.** This is a security boundary, not a format
1119    /// check. The key is built by flipping the last byte of the public key's coordinates, which
1120    /// almost certainly leaves the curve.
1121    #[test]
1122    fn qr_payload_rejects_off_curve_public_key() {
1123        let (payload, _pending) = begin().expect("randomness should be available");
1124        let mut bytes = payload.encode();
1125        bytes[PUBLIC_KEY_LEN] ^= 0x01;
1126        assert!(
1127            matches!(
1128                QrPayload::decode(&bytes),
1129                Err(Error::DevicePublicKeyInvalid { .. })
1130            ),
1131            "an off-curve public key must be rejected"
1132        );
1133    }
1134
1135    /// A missing required capability bit must be rejected.
1136    #[test]
1137    fn qr_payload_rejects_missing_capability() {
1138        let (payload, _pending) = begin().expect("randomness should be available");
1139        let mut bytes = payload.encode();
1140        bytes[QR_PAYLOAD_LEN - 1] = 0x00;
1141        assert!(matches!(
1142            QrPayload::decode(&bytes),
1143            Err(Error::PairingPayloadMalformed { .. })
1144        ));
1145    }
1146
1147    /// Every session must get a fresh ephemeral public key and nonce.
1148    #[test]
1149    fn each_session_gets_a_fresh_payload() {
1150        let (a, _pa) = begin().expect("randomness should be available");
1151        let (b, _pb) = begin().expect("randomness should be available");
1152        assert_ne!(a.nonce(), b.nonce(), "the nonce was reused");
1153        assert_ne!(
1154            a.public_key(),
1155            b.public_key(),
1156            "the ephemeral public key was reused"
1157        );
1158    }
1159
1160    /// **Tampering with any byte of `Msg1` must fail.**
1161    #[test]
1162    fn tampering_with_msg1_fails() {
1163        let master = MasterKey::generate().expect("randomness should be available");
1164        let identity = fake_identity();
1165        let (payload, pending) = begin().expect("randomness should be available");
1166        let joiner = join(&payload).expect("randomness should be available");
1167        let (baseline, _) = joiner.request(&identity).expect("Msg1 should be produced");
1168        assert_eq!(baseline.len(), MSG1_LEN);
1169
1170        for index in 0..baseline.len() {
1171            let (payload_i, pending_i) = begin().expect("randomness should be available");
1172            let joiner_i = join(&payload_i).expect("randomness should be available");
1173            let (mut tampered, _) = joiner_i
1174                .request(&identity)
1175                .expect("Msg1 should be produced");
1176            tampered[index] ^= 0x01;
1177            assert!(
1178                pending_i.accept(&tampered, &master).is_err(),
1179                "byte {index} of Msg1 was flipped, yet authentication passed"
1180            );
1181        }
1182        assert!(pending.accept(&baseline, &master).is_ok());
1183    }
1184
1185    /// **Tampering with any byte of `Msg2` must fail**, the master key itself included.
1186    #[test]
1187    fn tampering_with_msg2_fails() {
1188        let master = MasterKey::generate().expect("randomness should be available");
1189        let identity = fake_identity();
1190        let (payload, pending) = begin().expect("randomness should be available");
1191        let joiner = join(&payload).expect("randomness should be available");
1192        let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
1193        let adoption = pending
1194            .accept(&msg1, &master)
1195            .expect("it should authenticate");
1196        assert_eq!(adoption.reply().len(), MSG2_LEN);
1197
1198        let mut tampered = adoption.reply().to_vec();
1199        tampered[0] ^= 0x01;
1200        assert!(
1201            joiner.open(&tampered, &identity).is_err(),
1202            "the tampered master key still passed authentication"
1203        );
1204    }
1205
1206    /// Answering this session with **another session's** `Msg1` must fail.
1207    #[test]
1208    fn msg1_from_another_session_fails() {
1209        let master = MasterKey::generate().expect("randomness should be available");
1210        let identity = fake_identity();
1211        let (_payload_a, pending_a) = begin().expect("randomness should be available");
1212        let (payload_b, _pending_b) = begin().expect("randomness should be available");
1213        let joiner_b = join(&payload_b).expect("randomness should be available");
1214        let (msg1_b, _) = joiner_b
1215            .request(&identity)
1216            .expect("Msg1 should be produced");
1217
1218        assert!(
1219            pending_a.accept(&msg1_b, &master).is_err(),
1220            "a cross-session Msg1 must be rejected"
1221        );
1222    }
1223
1224    /// A message of the wrong length must be rejected, and must not panic.
1225    ///
1226    /// The length check must come **before** any authentication computation: otherwise a very long
1227    /// input would let an attacker buy a lot of computation for very little cost.
1228    #[test]
1229    fn wrong_length_messages_fail() {
1230        let master = MasterKey::generate().expect("randomness should be available");
1231        let identity = fake_identity();
1232
1233        let (payload, _pending) = begin().expect("randomness should be available");
1234        let joiner = join(&payload).expect("randomness should be available");
1235        let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
1236        for cut in [0_usize, 1, MSG1_LEN - 1] {
1237            let (p, pend) = begin().expect("randomness should be available");
1238            drop(join(&p).expect("randomness should be available"));
1239            assert!(
1240                pend.accept(&msg1[..cut], &master).is_err(),
1241                "length {cut} must be rejected"
1242            );
1243        }
1244        let mut too_long = msg1.clone();
1245        too_long.push(0);
1246        let (p, pend) = begin().expect("randomness should be available");
1247        drop(join(&p).expect("randomness should be available"));
1248        assert!(
1249            pend.accept(&too_long, &master).is_err(),
1250            "an over-long Msg1 must be rejected"
1251        );
1252
1253        for cut in [0_usize, 1, MSG2_LEN - 1] {
1254            let (p, pend) = begin().expect("randomness should be available");
1255            let j = join(&p).expect("randomness should be available");
1256            let (m1, _) = j.request(&identity).expect("Msg1 should be produced");
1257            let ad = pend.accept(&m1, &master).expect("it should authenticate");
1258            assert!(
1259                j.open(&ad.reply()[..cut], &identity).is_err(),
1260                "length {cut} must be rejected"
1261            );
1262        }
1263    }
1264
1265    /// A device identity public key of the wrong length must be rejected **before a message is sent**.
1266    #[test]
1267    fn request_rejects_wrong_identity_length() {
1268        let (payload, _pending) = begin().expect("randomness should be available");
1269        let joiner = join(&payload).expect("randomness should be available");
1270        for bad in [&[][..], &[0_u8; 64][..], &[0_u8; 66][..]] {
1271            assert!(
1272                matches!(
1273                    joiner.request(bad),
1274                    Err(Error::DevicePublicKeyInvalid { .. })
1275                ),
1276                "length {} must be rejected",
1277                bad.len()
1278            );
1279        }
1280    }
1281
1282    /// A "device identity public key" off the curve must be rejected.
1283    #[test]
1284    fn request_rejects_off_curve_identity() {
1285        let (payload, _pending) = begin().expect("randomness should be available");
1286        let joiner = join(&payload).expect("randomness should be available");
1287        let mut bad = fake_identity();
1288        bad[0] = 0x03;
1289        assert!(
1290            joiner.request(&bad).is_err(),
1291            "an invalid prefix must be rejected"
1292        );
1293    }
1294
1295    /// **Opening `Msg2` with a different identity public key must fail.**
1296    ///
1297    /// This pins down that the confirmation tag is bound to the peer identity: the tag is not a
1298    /// generic OK that says nothing about who the peer is.
1299    #[test]
1300    fn msg2_cannot_be_opened_with_a_different_identity() {
1301        let master = MasterKey::generate().expect("randomness should be available");
1302        let identity = fake_identity();
1303        let (payload, pending) = begin().expect("randomness should be available");
1304        let joiner = join(&payload).expect("randomness should be available");
1305        let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
1306        let adoption = pending
1307            .accept(&msg1, &master)
1308            .expect("it should authenticate");
1309
1310        assert!(
1311            joiner.open(adoption.reply(), &other_identity()).is_err(),
1312            "Msg2 could be opened with a different identity"
1313        );
1314    }
1315
1316    /// The nonce log must identify replays.
1317    #[test]
1318    fn nonce_log_detects_replay() {
1319        let mut log = NonceLog::new();
1320        let nonce = [7_u8; NONCE_LEN];
1321        assert!(!log.is_used(&nonce));
1322        assert!(log.record(nonce), "the first record should succeed");
1323        assert!(log.is_used(&nonce));
1324        assert!(
1325            !log.record(nonce),
1326            "a duplicate record must be recognized as a replay"
1327        );
1328        assert_eq!(log.len(), 1);
1329    }
1330
1331    /// The nonce log's capacity must be bounded; otherwise an attacker could exhaust it with pairings it never finishes.
1332    #[test]
1333    fn nonce_log_is_bounded() {
1334        let mut log = NonceLog::new();
1335        for i in 0..(NonceLog::DEFAULT_CAPACITY + 50) {
1336            let mut nonce = [0_u8; NONCE_LEN];
1337            nonce[..8].copy_from_slice(&(i as u64).to_be_bytes());
1338            assert!(log.record(nonce), "entry {i} should be recorded");
1339        }
1340        assert_eq!(log.len(), NonceLog::DEFAULT_CAPACITY);
1341    }
1342
1343    /// Two pairings must have different transcripts, and therefore different session keys.
1344    #[test]
1345    fn transcripts_differ_per_session() {
1346        let identity = fake_identity();
1347        let (payload_a, _pa) = begin().expect("randomness should be available");
1348        let (payload_b, _pb) = begin().expect("randomness should be available");
1349        let joiner_a = join(&payload_a).expect("randomness should be available");
1350        let joiner_b = join(&payload_b).expect("randomness should be available");
1351        let (_, sid_a) = joiner_a
1352            .request(&identity)
1353            .expect("Msg1 should be produced");
1354        let (_, sid_b) = joiner_b
1355            .request(&identity)
1356            .expect("Msg1 should be produced");
1357        assert_ne!(sid_a, sid_b, "two sessions produced the same transcript");
1358    }
1359
1360    /// Round trip of master key storage.
1361    #[test]
1362    fn master_key_storage_roundtrip() {
1363        let master = MasterKey::generate().expect("randomness should be available");
1364        let bytes = master_key_for_storage(&master);
1365        let rebuilt = master_key_from_storage(bytes).expect("the length is correct");
1366        assert_eq!(master.expose(), rebuilt.expose());
1367    }
1368
1369    /// The byte layout of `Msg1` is an external contract; pin it down.
1370    ///
1371    /// The key property: **the plaintext header carries only the nonce and the ephemeral public
1372    /// key, not the device identity public key**. The device identity is information for the two
1373    /// pairing parties and must not reach anyone who can read the pairing traffic.
1374    #[test]
1375    fn msg1_layout_keeps_device_identity_encrypted() {
1376        let identity = fake_identity();
1377        let (payload, _pending) = begin().expect("randomness should be available");
1378        let joiner = join(&payload).expect("randomness should be available");
1379        let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
1380
1381        assert_eq!(msg1.len(), MSG1_LEN);
1382        assert_eq!(MSG1_LEN, 32 + 65 + (65 + 32) + 16);
1383        assert_eq!(
1384            &msg1[..NONCE_LEN],
1385            &joiner_nonce_of(&msg1),
1386            "the nonce must come first"
1387        );
1388        assert_eq!(
1389            &msg1[NONCE_LEN..MSG1_HEADER_LEN],
1390            joiner.public_key(),
1391            "the plaintext header must carry the joiner's ephemeral public key"
1392        );
1393        assert!(
1394            !msg1.windows(PUBLIC_KEY_LEN).any(|w| w == identity),
1395            "the device identity public key appears in Msg1's plaintext part"
1396        );
1397    }
1398
1399    /// Take the nonce out of `Msg1` (test-only, so tests need not repeat the offset arithmetic).
1400    fn joiner_nonce_of(msg1: &[u8]) -> [u8; NONCE_LEN] {
1401        let mut out = [0_u8; NONCE_LEN];
1402        out.copy_from_slice(&msg1[..NONCE_LEN]);
1403        out
1404    }
1405
1406    #[test]
1407    fn probe_divergence() {
1408        let identity = fake_identity();
1409        let (payload, pending) = begin().expect("rng");
1410        let joiner = join(&payload).expect("rng");
1411        let (msg1, sid_j) = joiner.request(&identity).expect("req");
1412
1413        let (sid_j2, keys_j) = joiner.derive().expect("derive");
1414        println!(
1415            "joiner sid  request={} derive={}",
1416            hex(&sid_j),
1417            hex(&sid_j2)
1418        );
1419        println!("joiner k_ba  = {}", hex(&keys_j.joiner_to_initiator[..8]));
1420        println!("joiner nonce = {}", hex(&keys_j.nonce));
1421
1422        let mut nonce = [0u8; NONCE_LEN];
1423        nonce.copy_from_slice(&msg1[..NONCE_LEN]);
1424        let mut eph = [0u8; PUBLIC_KEY_LEN];
1425        eph.copy_from_slice(&msg1[NONCE_LEN..MSG1_HEADER_LEN]);
1426        let eph_pub = PublicKey::from_sec1_bytes(&eph).expect("on the curve");
1427        let sid_i = transcript_hash(
1428            &pending.payload.public_key,
1429            &pending.payload.nonce,
1430            &msg1[NONCE_LEN..MSG1_HEADER_LEN],
1431            &nonce,
1432        );
1433        println!("initiator sid = {}", hex(&sid_i));
1434        println!(
1435            "payload.pub == msg1 header eph? {}",
1436            pending.payload.public_key[..] == eph[..]
1437        );
1438        println!(
1439            "eph_pub == from header? {}",
1440            eph_pub.to_sec1_point(false).as_bytes() == &eph[..]
1441        );
1442        let shared = pending.secret.diffie_hellman(&eph_pub);
1443        let keys_i = SessionKeys::derive(shared.raw_secret_bytes(), &sid_i);
1444        println!("initiator k_ba = {}", hex(&keys_i.joiner_to_initiator[..8]));
1445        println!("initiator nonce = {}", hex(&keys_i.nonce));
1446        println!(
1447            "accept err = {:?}",
1448            pending.accept(&msg1, &MasterKey::generate().unwrap()).err()
1449        );
1450    }
1451
1452    fn hex(b: &[u8]) -> String {
1453        b.iter().map(|x| format!("{x:02x}")).collect()
1454    }
1455
1456    /// Stored bytes of the wrong length must be rejected.
1457    #[test]
1458    fn master_key_storage_rejects_wrong_length() {
1459        for len in [0_usize, 31, 33] {
1460            let bytes: SecretBytes = vec![0_u8; len].into();
1461            assert!(
1462                master_key_from_storage(bytes).is_err(),
1463                "length {len} must be rejected"
1464            );
1465        }
1466    }
1467
1468    /// **Debug output must not leak the master key.**
1469    #[test]
1470    fn debug_output_is_redacted() {
1471        let master = MasterKey::generate().expect("randomness should be available");
1472        let identity = fake_identity();
1473        let (payload, pending) = begin().expect("randomness should be available");
1474        let joiner = join(&payload).expect("randomness should be available");
1475        let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
1476        let joiner_debug = format!("{joiner:?}");
1477        let adoption = pending
1478            .accept(&msg1, &master)
1479            .expect("it should authenticate");
1480        let _ = joiner
1481            .open(adoption.reply(), &identity)
1482            .expect("it should open");
1483
1484        let texts = [
1485            format!("{payload:?}"),
1486            joiner_debug,
1487            format!("{adoption:?}"),
1488        ];
1489        for text in &texts {
1490            assert!(
1491                !text.contains("XChaCha"),
1492                "no internal type name should appear"
1493            );
1494        }
1495        let hex: String = master.expose().iter().map(|b| format!("{b:02x}")).collect();
1496        for text in &texts {
1497            assert!(
1498                !text.contains(&hex),
1499                "Debug output leaked the master key in hex: {text}"
1500            );
1501        }
1502    }
1503
1504    /// `Adoption::reply` **really does carry** the master key; this guards against a change that
1505    /// silently makes pairing succeed while transferring nothing.
1506    #[test]
1507    fn reply_actually_carries_the_master_key() {
1508        let (a, b, _, _) = run_pairing();
1509        assert_eq!(a.expose(), b.expose());
1510        assert_ne!(
1511            a.expose(),
1512            &[0_u8; MASTER_KEY_LEN],
1513            "the master key must not be all zeros"
1514        );
1515    }
1516}