1use 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
119pub const PROTOCOL_VERSION: u8 = 1;
125
126pub const PUBLIC_KEY_LEN: usize = 65;
128
129pub const NONCE_LEN: usize = 32;
135
136pub const CONFIRMATION_LEN: usize = 32;
142
143pub const SESSION_ID_LEN: usize = 32;
145
146pub const TAG_LEN: usize = 16;
148
149pub const QR_PAYLOAD_LEN: usize = 1 + PUBLIC_KEY_LEN + NONCE_LEN + 1;
151
152pub const MSG1_HEADER_LEN: usize = NONCE_LEN + PUBLIC_KEY_LEN;
163
164pub const MSG1_BODY_PLAINTEXT_LEN: usize = PUBLIC_KEY_LEN + CONFIRMATION_LEN;
172
173pub const MSG1_LEN: usize = MSG1_HEADER_LEN + MSG1_BODY_PLAINTEXT_LEN + TAG_LEN;
175
176pub const MSG2_PLAINTEXT_LEN: usize = MASTER_KEY_LEN + CONFIRMATION_LEN;
178
179pub const MSG2_LEN: usize = MSG2_PLAINTEXT_LEN + TAG_LEN;
181
182const CAPABILITY_DEVICE_IDENTITY: u8 = 0x01;
189
190const HKDF_INFO_JOINER_TO_INITIATOR: &[u8] = b"Qualia2FA pairing v1 joiner-to-initiator";
192
193const HKDF_INFO_INITIATOR_TO_JOINER: &[u8] = b"Qualia2FA pairing v1 initiator-to-joiner";
195
196const CONFIRMATION_LABEL: &[u8] = b"Qualia2FA pairing v1 confirmation";
198
199const NONCE_LABEL: &[u8] = b"Qualia2FA pairing v1 aead nonce";
201
202#[derive(Clone, PartialEq, Eq)]
211pub struct QrPayload {
212 public_key: [u8; PUBLIC_KEY_LEN],
214 nonce: [u8; NONCE_LEN],
216 capabilities: u8,
218}
219
220impl QrPayload {
221 #[must_use]
223 pub const fn public_key(&self) -> &[u8; PUBLIC_KEY_LEN] {
224 &self.public_key
225 }
226
227 #[must_use]
229 pub const fn nonce(&self) -> &[u8; NONCE_LEN] {
230 &self.nonce
231 }
232
233 #[must_use]
235 pub const fn capabilities(&self) -> u8 {
236 self.capabilities
237 }
238
239 #[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 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 #[must_use]
298 pub fn to_base32(&self) -> String {
299 crate::base32::encode(&self.encode())
300 }
301
302 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 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
329pub struct PendingSession {
345 secret: EphemeralSecret,
347 payload: QrPayload,
349}
350
351impl PendingSession {
352 #[must_use]
354 pub const fn payload(&self) -> &QrPayload {
355 &self.payload
356 }
357
358 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
464pub struct Adoption {
466 session_id: [u8; SESSION_ID_LEN],
468 joiner_identity: [u8; PUBLIC_KEY_LEN],
470 reply: Vec<u8>,
472}
473
474impl Adoption {
475 #[must_use]
477 pub const fn session_id(&self) -> &[u8; SESSION_ID_LEN] {
478 &self.session_id
479 }
480
481 #[must_use]
489 pub const fn joiner_identity(&self) -> &[u8; PUBLIC_KEY_LEN] {
490 &self.joiner_identity
491 }
492
493 #[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
510pub struct JoinerSession {
516 secret: EphemeralSecret,
518 public_key: [u8; PUBLIC_KEY_LEN],
520 nonce: [u8; NONCE_LEN],
522 payload: QrPayload,
524}
525
526impl JoinerSession {
527 #[must_use]
529 pub const fn public_key(&self) -> &[u8; PUBLIC_KEY_LEN] {
530 &self.public_key
531 }
532
533 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum MessageKind {
680 Request,
682 Accept,
684}
685
686impl MessageKind {
687 const fn as_byte(self) -> u8 {
689 match self {
690 Self::Request => 0x01,
691 Self::Accept => 0x02,
692 }
693 }
694}
695
696struct SessionKeys {
702 joiner_to_initiator: [u8; 32],
704 initiator_to_joiner: [u8; 32],
706 nonce: [u8; AEAD_NONCE_LEN],
708}
709
710impl SessionKeys {
711 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
761fn 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
787fn 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
800fn 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
828fn 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#[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
871pub 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#[must_use]
907pub fn master_key_for_storage(master: &MasterKey) -> SecretBytes {
908 master_key_to_bytes(master)
909}
910
911pub fn master_key_from_storage(bytes: SecretBytes) -> Result<MasterKey> {
917 MasterKey::from_bytes(bytes)
918}
919
920#[derive(Debug, Clone, Default)]
937pub struct NonceLog {
938 used: std::collections::VecDeque<[u8; NONCE_LEN]>,
940}
941
942impl NonceLog {
943 pub const DEFAULT_CAPACITY: usize = 256;
945
946 #[must_use]
948 pub fn new() -> Self {
949 Self::default()
950 }
951
952 #[must_use]
954 pub fn len(&self) -> usize {
955 self.used.len()
956 }
957
958 #[must_use]
960 pub fn is_empty(&self) -> bool {
961 self.used.is_empty()
962 }
963
964 #[must_use]
966 pub fn is_used(&self, nonce: &[u8; NONCE_LEN]) -> bool {
967 self.used.contains(nonce)
968 }
969
970 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}