use crate::vault::{MASTER_KEY_LEN, MasterKey, NONCE_LEN as AEAD_NONCE_LEN, master_key_to_bytes};
use crate::{Error, ExposeSecret, Result, SecretBytes};
use chacha20poly1305::aead::{AeadInOut, KeyInit};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use p256::PublicKey;
use p256::ecdh::EphemeralSecret;
use p256::elliptic_curve::Generate;
use p256::elliptic_curve::sec1::ToSec1Point as _;
use secrecy::zeroize::Zeroize;
use sha2::{Digest, Sha256};
pub const PROTOCOL_VERSION: u8 = 1;
pub const PUBLIC_KEY_LEN: usize = 65;
pub const NONCE_LEN: usize = 32;
pub const CONFIRMATION_LEN: usize = 32;
pub const SESSION_ID_LEN: usize = 32;
pub const TAG_LEN: usize = 16;
pub const QR_PAYLOAD_LEN: usize = 1 + PUBLIC_KEY_LEN + NONCE_LEN + 1;
pub const MSG1_HEADER_LEN: usize = NONCE_LEN + PUBLIC_KEY_LEN;
pub const MSG1_BODY_PLAINTEXT_LEN: usize = PUBLIC_KEY_LEN + CONFIRMATION_LEN;
pub const MSG1_LEN: usize = MSG1_HEADER_LEN + MSG1_BODY_PLAINTEXT_LEN + TAG_LEN;
pub const MSG2_PLAINTEXT_LEN: usize = MASTER_KEY_LEN + CONFIRMATION_LEN;
pub const MSG2_LEN: usize = MSG2_PLAINTEXT_LEN + TAG_LEN;
const CAPABILITY_DEVICE_IDENTITY: u8 = 0x01;
const HKDF_INFO_JOINER_TO_INITIATOR: &[u8] = b"Qualia2FA pairing v1 joiner-to-initiator";
const HKDF_INFO_INITIATOR_TO_JOINER: &[u8] = b"Qualia2FA pairing v1 initiator-to-joiner";
const CONFIRMATION_LABEL: &[u8] = b"Qualia2FA pairing v1 confirmation";
const NONCE_LABEL: &[u8] = b"Qualia2FA pairing v1 aead nonce";
#[derive(Clone, PartialEq, Eq)]
pub struct QrPayload {
public_key: [u8; PUBLIC_KEY_LEN],
nonce: [u8; NONCE_LEN],
capabilities: u8,
}
impl QrPayload {
#[must_use]
pub const fn public_key(&self) -> &[u8; PUBLIC_KEY_LEN] {
&self.public_key
}
#[must_use]
pub const fn nonce(&self) -> &[u8; NONCE_LEN] {
&self.nonce
}
#[must_use]
pub const fn capabilities(&self) -> u8 {
self.capabilities
}
#[must_use]
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(QR_PAYLOAD_LEN);
out.push(PROTOCOL_VERSION);
out.extend_from_slice(&self.public_key);
out.extend_from_slice(&self.nonce);
out.push(self.capabilities);
out
}
pub fn decode(bytes: &[u8]) -> Result<Self> {
if bytes.len() != QR_PAYLOAD_LEN {
return Err(Error::PairingPayloadMalformed {
reason: "the length is not 1 + 65 + 32 + 1 bytes",
});
}
if bytes[0] != PROTOCOL_VERSION {
return Err(Error::PairingUnsupportedVersion { version: bytes[0] });
}
let mut public_key = [0_u8; PUBLIC_KEY_LEN];
public_key.copy_from_slice(&bytes[1..1 + PUBLIC_KEY_LEN]);
let mut nonce = [0_u8; NONCE_LEN];
nonce.copy_from_slice(&bytes[1 + PUBLIC_KEY_LEN..1 + PUBLIC_KEY_LEN + NONCE_LEN]);
let capabilities = bytes[QR_PAYLOAD_LEN - 1];
PublicKey::from_sec1_bytes(&public_key).map_err(|_| Error::DevicePublicKeyInvalid {
reason: "the ephemeral public key in the QR code is not a valid P-256 SEC1 point",
})?;
if capabilities & CAPABILITY_DEVICE_IDENTITY == 0 {
return Err(Error::PairingPayloadMalformed {
reason: "the QR code is missing a required capability bit",
});
}
Ok(Self {
public_key,
nonce,
capabilities,
})
}
#[must_use]
pub fn to_base32(&self) -> String {
crate::base32::encode(&self.encode())
}
pub fn from_base32(text: &str) -> Result<Self> {
let bytes = crate::base32::decode(text)?;
Self::decode(bytes.expose_secret())
}
}
impl core::fmt::Debug for QrPayload {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("QrPayload")
.field("version", &PROTOCOL_VERSION)
.field("public_key", &"<65 bytes>")
.field("nonce", &"<redacted>")
.field("capabilities", &format_args!("0x{:02X}", self.capabilities))
.finish()
}
}
pub struct PendingSession {
secret: EphemeralSecret,
payload: QrPayload,
}
impl PendingSession {
#[must_use]
pub const fn payload(&self) -> &QrPayload {
&self.payload
}
pub fn accept(self, msg1: &[u8], master: &MasterKey) -> Result<Adoption> {
if msg1.len() != MSG1_LEN {
return Err(Error::PairingAuthenticationFailed);
}
let mut joiner_nonce = [0_u8; NONCE_LEN];
joiner_nonce.copy_from_slice(&msg1[..NONCE_LEN]);
let mut joiner_ephemeral = [0_u8; PUBLIC_KEY_LEN];
joiner_ephemeral.copy_from_slice(&msg1[NONCE_LEN..MSG1_HEADER_LEN]);
let joiner_ephemeral = PublicKey::from_sec1_bytes(&joiner_ephemeral).map_err(|_| {
Error::DevicePublicKeyInvalid {
reason: "the joiner's ephemeral public key is not a valid P-256 SEC1 point",
}
})?;
let transcript = transcript_hash(
&self.payload.public_key,
&self.payload.nonce,
&msg1[NONCE_LEN..MSG1_HEADER_LEN],
&joiner_nonce,
);
let shared = self.secret.diffie_hellman(&joiner_ephemeral);
let keys = SessionKeys::derive(shared.raw_secret_bytes(), &transcript);
let mut plaintext = msg1[MSG1_HEADER_LEN..].to_vec();
let aad = aad_for(&transcript, MessageKind::Request);
let cipher = XChaCha20Poly1305::new(&Key::from(keys.joiner_to_initiator));
cipher
.decrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut plaintext)
.map_err(|_| Error::PairingAuthenticationFailed)?;
debug_assert_eq!(plaintext.len(), MSG1_BODY_PLAINTEXT_LEN);
let mut joiner_identity = [0_u8; PUBLIC_KEY_LEN];
joiner_identity.copy_from_slice(&plaintext[..PUBLIC_KEY_LEN]);
let expected = confirmation_tag(
&keys.joiner_to_initiator,
&transcript,
MessageKind::Request,
&joiner_identity,
);
if !constant_time_eq(&plaintext[PUBLIC_KEY_LEN..], &expected) {
return Err(Error::PairingAuthenticationFailed);
}
PublicKey::from_sec1_bytes(&joiner_identity).map_err(|_| {
Error::DevicePublicKeyInvalid {
reason: "the joiner's device identity public key is not a valid P-256 SEC1 point",
}
})?;
let mut reply = Vec::with_capacity(MSG2_PLAINTEXT_LEN);
reply.extend_from_slice(master.expose());
reply.extend_from_slice(&confirmation_tag(
&keys.initiator_to_joiner,
&transcript,
MessageKind::Accept,
&joiner_identity,
));
let aad = aad_for(&transcript, MessageKind::Accept);
let reply_cipher = XChaCha20Poly1305::new(&Key::from(keys.initiator_to_joiner));
reply_cipher
.encrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut reply)
.map_err(|_| Error::PairingAuthenticationFailed)?;
Ok(Adoption {
session_id: transcript,
joiner_identity,
reply,
})
}
}
impl core::fmt::Debug for PendingSession {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("PendingSession")
.field("payload", &self.payload)
.field("secret", &"<redacted>")
.finish()
}
}
pub struct Adoption {
session_id: [u8; SESSION_ID_LEN],
joiner_identity: [u8; PUBLIC_KEY_LEN],
reply: Vec<u8>,
}
impl Adoption {
#[must_use]
pub const fn session_id(&self) -> &[u8; SESSION_ID_LEN] {
&self.session_id
}
#[must_use]
pub const fn joiner_identity(&self) -> &[u8; PUBLIC_KEY_LEN] {
&self.joiner_identity
}
#[must_use]
pub fn reply(&self) -> &[u8] {
&self.reply
}
}
impl core::fmt::Debug for Adoption {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("Adoption")
.field("session_id", &&self.session_id[..8])
.field("joiner_identity", &"<65 bytes>")
.field("reply_len", &self.reply.len())
.finish()
}
}
pub struct JoinerSession {
secret: EphemeralSecret,
public_key: [u8; PUBLIC_KEY_LEN],
nonce: [u8; NONCE_LEN],
payload: QrPayload,
}
impl JoinerSession {
#[must_use]
pub const fn public_key(&self) -> &[u8; PUBLIC_KEY_LEN] {
&self.public_key
}
pub fn request(&self, device_identity: &[u8]) -> Result<(Vec<u8>, [u8; SESSION_ID_LEN])> {
if device_identity.len() != PUBLIC_KEY_LEN {
return Err(Error::DevicePublicKeyInvalid {
reason: "the device identity public key is not 65 bytes long",
});
}
PublicKey::from_sec1_bytes(device_identity).map_err(|_| Error::DevicePublicKeyInvalid {
reason: "the device identity public key is not a valid P-256 SEC1 point",
})?;
let (transcript, keys) = self.derive()?;
let mut body = Vec::with_capacity(MSG1_BODY_PLAINTEXT_LEN);
body.extend_from_slice(device_identity);
body.extend_from_slice(&confirmation_tag(
&keys.joiner_to_initiator,
&transcript,
MessageKind::Request,
device_identity,
));
let aad = aad_for(&transcript, MessageKind::Request);
let cipher = XChaCha20Poly1305::new(&Key::from(keys.joiner_to_initiator));
cipher
.encrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut body)
.map_err(|_| Error::PairingAuthenticationFailed)?;
let mut msg = Vec::with_capacity(MSG1_LEN);
msg.extend_from_slice(&self.nonce);
msg.extend_from_slice(&self.public_key);
msg.extend_from_slice(&body);
Ok((msg, transcript))
}
pub fn open(self, msg2: &[u8], device_identity: &[u8]) -> Result<MasterKey> {
if msg2.len() != MSG2_LEN {
return Err(Error::PairingAuthenticationFailed);
}
if device_identity.len() != PUBLIC_KEY_LEN {
return Err(Error::DevicePublicKeyInvalid {
reason: "the device identity public key is not 65 bytes long",
});
}
let (transcript, keys) = self.derive()?;
let mut plaintext = msg2.to_vec();
let aad = aad_for(&transcript, MessageKind::Accept);
let cipher = XChaCha20Poly1305::new(&Key::from(keys.initiator_to_joiner));
cipher
.decrypt_in_place(&XNonce::from(keys.nonce), &aad, &mut plaintext)
.map_err(|_| Error::PairingAuthenticationFailed)?;
debug_assert_eq!(plaintext.len(), MSG2_PLAINTEXT_LEN);
let expected = confirmation_tag(
&keys.initiator_to_joiner,
&transcript,
MessageKind::Accept,
device_identity,
);
if !constant_time_eq(&plaintext[MASTER_KEY_LEN..], &expected) {
return Err(Error::PairingAuthenticationFailed);
}
let mut raw = plaintext[..MASTER_KEY_LEN].to_vec();
let secret: SecretBytes = core::mem::take(&mut raw).into();
raw.zeroize();
MasterKey::from_bytes(secret)
}
fn derive(&self) -> Result<([u8; SESSION_ID_LEN], SessionKeys)> {
let initiator_public = PublicKey::from_sec1_bytes(&self.payload.public_key).map_err(
|_| Error::DevicePublicKeyInvalid {
reason: "the ephemeral public key in the QR code is not a valid P-256 SEC1 point",
},
)?;
let transcript = transcript_hash(
&self.payload.public_key,
&self.payload.nonce,
&self.public_key,
&self.nonce,
);
let shared = self.secret.diffie_hellman(&initiator_public);
Ok((
transcript,
SessionKeys::derive(shared.raw_secret_bytes(), &transcript),
))
}
}
impl core::fmt::Debug for JoinerSession {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("JoinerSession")
.field("payload", &self.payload)
.field("public_key", &"<65 bytes>")
.field("nonce", &"<redacted>")
.field("secret", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageKind {
Request,
Accept,
}
impl MessageKind {
const fn as_byte(self) -> u8 {
match self {
Self::Request => 0x01,
Self::Accept => 0x02,
}
}
}
struct SessionKeys {
joiner_to_initiator: [u8; 32],
initiator_to_joiner: [u8; 32],
nonce: [u8; AEAD_NONCE_LEN],
}
impl SessionKeys {
fn derive(shared: &[u8], transcript: &[u8; SESSION_ID_LEN]) -> Self {
let hkdf = Hkdf::<Sha256>::new(Some(transcript), shared);
let mut joiner_to_initiator = [0_u8; 32];
let mut initiator_to_joiner = [0_u8; 32];
let failed = hkdf
.expand(HKDF_INFO_JOINER_TO_INITIATOR, &mut joiner_to_initiator)
.is_err()
|| hkdf
.expand(HKDF_INFO_INITIATOR_TO_JOINER, &mut initiator_to_joiner)
.is_err();
if failed {
joiner_to_initiator.zeroize();
initiator_to_joiner.zeroize();
}
let mut hasher = Sha256::new();
hasher.update(NONCE_LABEL);
hasher.update(transcript);
let digest = hasher.finalize();
let mut nonce = [0_u8; AEAD_NONCE_LEN];
nonce.copy_from_slice(&digest[..AEAD_NONCE_LEN]);
Self {
joiner_to_initiator,
initiator_to_joiner,
nonce,
}
}
}
impl Drop for SessionKeys {
fn drop(&mut self) {
self.joiner_to_initiator.zeroize();
self.initiator_to_joiner.zeroize();
self.nonce.zeroize();
}
}
fn transcript_hash(
initiator_public_key: &[u8; PUBLIC_KEY_LEN],
initiator_nonce: &[u8; NONCE_LEN],
joiner_public_key: &[u8],
joiner_nonce: &[u8; NONCE_LEN],
) -> [u8; SESSION_ID_LEN] {
let mut hasher = Sha256::new();
hasher.update([PROTOCOL_VERSION]);
hasher.update(initiator_public_key);
hasher.update(initiator_nonce);
hasher.update(joiner_public_key);
hasher.update(joiner_nonce);
let digest = hasher.finalize();
let mut out = [0_u8; SESSION_ID_LEN];
out.copy_from_slice(&digest);
out
}
fn aad_for(transcript: &[u8; SESSION_ID_LEN], kind: MessageKind) -> [u8; SESSION_ID_LEN + 1] {
let mut aad = [0_u8; SESSION_ID_LEN + 1];
aad[..SESSION_ID_LEN].copy_from_slice(transcript);
aad[SESSION_ID_LEN] = kind.as_byte();
aad
}
fn confirmation_tag(
key: &[u8; 32],
transcript: &[u8; SESSION_ID_LEN],
kind: MessageKind,
peer_identity: &[u8],
) -> [u8; CONFIRMATION_LEN] {
let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(key) else {
return [0_u8; CONFIRMATION_LEN];
};
mac.update(CONFIRMATION_LABEL);
mac.update(&[kind.as_byte()]);
mac.update(transcript);
mac.update(peer_identity);
let digest = mac.finalize().into_bytes();
let mut out = [0_u8; CONFIRMATION_LEN];
out.copy_from_slice(&digest);
out
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
if a.len() != b.len() {
return false;
}
a.ct_eq(b).into()
}
#[must_use = "the pairing session must be kept, or the public key in the QR code has no matching private key"]
pub fn begin() -> Result<(QrPayload, PendingSession)> {
let secret = EphemeralSecret::generate();
let public_key = secret.public_key().to_sec1_point(false);
let mut public_key_bytes = [0_u8; PUBLIC_KEY_LEN];
public_key_bytes.copy_from_slice(public_key.as_bytes());
let mut nonce = [0_u8; NONCE_LEN];
crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
let payload = QrPayload {
public_key: public_key_bytes,
nonce,
capabilities: CAPABILITY_DEVICE_IDENTITY,
};
let session = PendingSession {
secret,
payload: payload.clone(),
};
Ok((payload, session))
}
pub fn join(payload: &QrPayload) -> Result<JoinerSession> {
PublicKey::from_sec1_bytes(&payload.public_key).map_err(|_| Error::DevicePublicKeyInvalid {
reason: "the ephemeral public key in the QR code is not a valid P-256 SEC1 point",
})?;
let secret = EphemeralSecret::generate();
let public_key = secret.public_key().to_sec1_point(false);
let mut public_key_bytes = [0_u8; PUBLIC_KEY_LEN];
public_key_bytes.copy_from_slice(public_key.as_bytes());
let mut nonce = [0_u8; NONCE_LEN];
crate::getrandom::fill(&mut nonce).map_err(|_| Error::RandomnessUnavailable)?;
Ok(JoinerSession {
secret,
public_key: public_key_bytes,
nonce,
payload: payload.clone(),
})
}
#[must_use]
pub fn master_key_for_storage(master: &MasterKey) -> SecretBytes {
master_key_to_bytes(master)
}
pub fn master_key_from_storage(bytes: SecretBytes) -> Result<MasterKey> {
MasterKey::from_bytes(bytes)
}
#[derive(Debug, Clone, Default)]
pub struct NonceLog {
used: std::collections::VecDeque<[u8; NONCE_LEN]>,
}
impl NonceLog {
pub const DEFAULT_CAPACITY: usize = 256;
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.used.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.used.is_empty()
}
#[must_use]
pub fn is_used(&self, nonce: &[u8; NONCE_LEN]) -> bool {
self.used.contains(nonce)
}
pub fn record(&mut self, nonce: [u8; NONCE_LEN]) -> bool {
if self.is_used(&nonce) {
return false;
}
if self.used.len() >= Self::DEFAULT_CAPACITY {
self.used.pop_front();
}
self.used.push_back(nonce);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use p256::SecretKey;
fn fake_identity() -> [u8; PUBLIC_KEY_LEN] {
public_key_of(&SecretKey::from_bytes(&[1_u8; 32].into()).expect("scalar 1 is valid"))
}
fn other_identity() -> [u8; PUBLIC_KEY_LEN] {
public_key_of(&SecretKey::from_bytes(&[2_u8; 32].into()).expect("scalar 2 is valid"))
}
fn public_key_of(secret: &SecretKey) -> [u8; PUBLIC_KEY_LEN] {
let point = secret.public_key().to_sec1_point(false);
let mut out = [0_u8; PUBLIC_KEY_LEN];
out.copy_from_slice(point.as_bytes());
out
}
fn run_pairing() -> (
MasterKey,
MasterKey,
[u8; SESSION_ID_LEN],
[u8; SESSION_ID_LEN],
) {
let initiator_master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (payload, pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (msg1, joiner_sid) = joiner.request(&identity).expect("Msg1 should be produced");
let adoption = pending
.accept(&msg1, &initiator_master)
.expect("Msg1 should authenticate");
assert_eq!(
adoption.joiner_identity(),
&identity,
"the initiator should see the joiner's device identity"
);
let joiner_master = joiner
.open(adoption.reply(), &identity)
.expect("Msg2 should authenticate");
(
initiator_master,
joiner_master,
*adoption.session_id(),
joiner_sid,
)
}
#[test]
fn happy_path_transfers_the_master_key() {
let (a, b, sid_a, sid_b) = run_pairing();
assert_eq!(
a.expose(),
b.expose(),
"both sides must agree on the master key"
);
assert_eq!(
sid_a, sid_b,
"both sides must agree on the session identifier"
);
}
#[test]
fn qr_payload_roundtrips_through_base32() {
let (payload, _pending) = begin().expect("randomness should be available");
let text = payload.to_base32();
let decoded = QrPayload::from_base32(&text).expect("it should decode");
assert_eq!(decoded, payload);
}
#[test]
fn qr_payload_layout_is_stable() {
let (payload, _pending) = begin().expect("randomness should be available");
let bytes = payload.encode();
assert_eq!(bytes.len(), QR_PAYLOAD_LEN);
assert_eq!(bytes[0], PROTOCOL_VERSION);
assert_eq!(&bytes[1..66], payload.public_key());
assert_eq!(&bytes[66..98], payload.nonce());
assert_eq!(bytes[98], CAPABILITY_DEVICE_IDENTITY);
}
#[test]
fn qr_payload_rejects_wrong_length() {
let (payload, _pending) = begin().expect("randomness should be available");
let bytes = payload.encode();
for cut in [0_usize, 1, QR_PAYLOAD_LEN - 1] {
assert!(
matches!(
QrPayload::decode(&bytes[..cut]),
Err(Error::PairingPayloadMalformed { .. })
),
"length {cut} must be rejected"
);
}
let mut longer = bytes.clone();
longer.push(0);
assert!(QrPayload::decode(&longer).is_err());
}
#[test]
fn qr_payload_rejects_unknown_version() {
let (payload, _pending) = begin().expect("randomness should be available");
let mut bytes = payload.encode();
bytes[0] = 0x7F;
match QrPayload::decode(&bytes) {
Err(Error::PairingUnsupportedVersion { version }) => assert_eq!(version, 0x7F),
other => panic!(
"an unsupported version should be reported, got is_ok={}",
other.is_ok()
),
}
}
#[test]
fn qr_payload_rejects_off_curve_public_key() {
let (payload, _pending) = begin().expect("randomness should be available");
let mut bytes = payload.encode();
bytes[PUBLIC_KEY_LEN] ^= 0x01;
assert!(
matches!(
QrPayload::decode(&bytes),
Err(Error::DevicePublicKeyInvalid { .. })
),
"an off-curve public key must be rejected"
);
}
#[test]
fn qr_payload_rejects_missing_capability() {
let (payload, _pending) = begin().expect("randomness should be available");
let mut bytes = payload.encode();
bytes[QR_PAYLOAD_LEN - 1] = 0x00;
assert!(matches!(
QrPayload::decode(&bytes),
Err(Error::PairingPayloadMalformed { .. })
));
}
#[test]
fn each_session_gets_a_fresh_payload() {
let (a, _pa) = begin().expect("randomness should be available");
let (b, _pb) = begin().expect("randomness should be available");
assert_ne!(a.nonce(), b.nonce(), "the nonce was reused");
assert_ne!(
a.public_key(),
b.public_key(),
"the ephemeral public key was reused"
);
}
#[test]
fn tampering_with_msg1_fails() {
let master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (payload, pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (baseline, _) = joiner.request(&identity).expect("Msg1 should be produced");
assert_eq!(baseline.len(), MSG1_LEN);
for index in 0..baseline.len() {
let (payload_i, pending_i) = begin().expect("randomness should be available");
let joiner_i = join(&payload_i).expect("randomness should be available");
let (mut tampered, _) = joiner_i
.request(&identity)
.expect("Msg1 should be produced");
tampered[index] ^= 0x01;
assert!(
pending_i.accept(&tampered, &master).is_err(),
"byte {index} of Msg1 was flipped, yet authentication passed"
);
}
assert!(pending.accept(&baseline, &master).is_ok());
}
#[test]
fn tampering_with_msg2_fails() {
let master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (payload, pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
let adoption = pending
.accept(&msg1, &master)
.expect("it should authenticate");
assert_eq!(adoption.reply().len(), MSG2_LEN);
let mut tampered = adoption.reply().to_vec();
tampered[0] ^= 0x01;
assert!(
joiner.open(&tampered, &identity).is_err(),
"the tampered master key still passed authentication"
);
}
#[test]
fn msg1_from_another_session_fails() {
let master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (_payload_a, pending_a) = begin().expect("randomness should be available");
let (payload_b, _pending_b) = begin().expect("randomness should be available");
let joiner_b = join(&payload_b).expect("randomness should be available");
let (msg1_b, _) = joiner_b
.request(&identity)
.expect("Msg1 should be produced");
assert!(
pending_a.accept(&msg1_b, &master).is_err(),
"a cross-session Msg1 must be rejected"
);
}
#[test]
fn wrong_length_messages_fail() {
let master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (payload, _pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
for cut in [0_usize, 1, MSG1_LEN - 1] {
let (p, pend) = begin().expect("randomness should be available");
drop(join(&p).expect("randomness should be available"));
assert!(
pend.accept(&msg1[..cut], &master).is_err(),
"length {cut} must be rejected"
);
}
let mut too_long = msg1.clone();
too_long.push(0);
let (p, pend) = begin().expect("randomness should be available");
drop(join(&p).expect("randomness should be available"));
assert!(
pend.accept(&too_long, &master).is_err(),
"an over-long Msg1 must be rejected"
);
for cut in [0_usize, 1, MSG2_LEN - 1] {
let (p, pend) = begin().expect("randomness should be available");
let j = join(&p).expect("randomness should be available");
let (m1, _) = j.request(&identity).expect("Msg1 should be produced");
let ad = pend.accept(&m1, &master).expect("it should authenticate");
assert!(
j.open(&ad.reply()[..cut], &identity).is_err(),
"length {cut} must be rejected"
);
}
}
#[test]
fn request_rejects_wrong_identity_length() {
let (payload, _pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
for bad in [&[][..], &[0_u8; 64][..], &[0_u8; 66][..]] {
assert!(
matches!(
joiner.request(bad),
Err(Error::DevicePublicKeyInvalid { .. })
),
"length {} must be rejected",
bad.len()
);
}
}
#[test]
fn request_rejects_off_curve_identity() {
let (payload, _pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let mut bad = fake_identity();
bad[0] = 0x03;
assert!(
joiner.request(&bad).is_err(),
"an invalid prefix must be rejected"
);
}
#[test]
fn msg2_cannot_be_opened_with_a_different_identity() {
let master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (payload, pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
let adoption = pending
.accept(&msg1, &master)
.expect("it should authenticate");
assert!(
joiner.open(adoption.reply(), &other_identity()).is_err(),
"Msg2 could be opened with a different identity"
);
}
#[test]
fn nonce_log_detects_replay() {
let mut log = NonceLog::new();
let nonce = [7_u8; NONCE_LEN];
assert!(!log.is_used(&nonce));
assert!(log.record(nonce), "the first record should succeed");
assert!(log.is_used(&nonce));
assert!(
!log.record(nonce),
"a duplicate record must be recognized as a replay"
);
assert_eq!(log.len(), 1);
}
#[test]
fn nonce_log_is_bounded() {
let mut log = NonceLog::new();
for i in 0..(NonceLog::DEFAULT_CAPACITY + 50) {
let mut nonce = [0_u8; NONCE_LEN];
nonce[..8].copy_from_slice(&(i as u64).to_be_bytes());
assert!(log.record(nonce), "entry {i} should be recorded");
}
assert_eq!(log.len(), NonceLog::DEFAULT_CAPACITY);
}
#[test]
fn transcripts_differ_per_session() {
let identity = fake_identity();
let (payload_a, _pa) = begin().expect("randomness should be available");
let (payload_b, _pb) = begin().expect("randomness should be available");
let joiner_a = join(&payload_a).expect("randomness should be available");
let joiner_b = join(&payload_b).expect("randomness should be available");
let (_, sid_a) = joiner_a
.request(&identity)
.expect("Msg1 should be produced");
let (_, sid_b) = joiner_b
.request(&identity)
.expect("Msg1 should be produced");
assert_ne!(sid_a, sid_b, "two sessions produced the same transcript");
}
#[test]
fn master_key_storage_roundtrip() {
let master = MasterKey::generate().expect("randomness should be available");
let bytes = master_key_for_storage(&master);
let rebuilt = master_key_from_storage(bytes).expect("the length is correct");
assert_eq!(master.expose(), rebuilt.expose());
}
#[test]
fn msg1_layout_keeps_device_identity_encrypted() {
let identity = fake_identity();
let (payload, _pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
assert_eq!(msg1.len(), MSG1_LEN);
assert_eq!(MSG1_LEN, 32 + 65 + (65 + 32) + 16);
assert_eq!(
&msg1[..NONCE_LEN],
&joiner_nonce_of(&msg1),
"the nonce must come first"
);
assert_eq!(
&msg1[NONCE_LEN..MSG1_HEADER_LEN],
joiner.public_key(),
"the plaintext header must carry the joiner's ephemeral public key"
);
assert!(
!msg1.windows(PUBLIC_KEY_LEN).any(|w| w == identity),
"the device identity public key appears in Msg1's plaintext part"
);
}
fn joiner_nonce_of(msg1: &[u8]) -> [u8; NONCE_LEN] {
let mut out = [0_u8; NONCE_LEN];
out.copy_from_slice(&msg1[..NONCE_LEN]);
out
}
#[test]
fn probe_divergence() {
let identity = fake_identity();
let (payload, pending) = begin().expect("rng");
let joiner = join(&payload).expect("rng");
let (msg1, sid_j) = joiner.request(&identity).expect("req");
let (sid_j2, keys_j) = joiner.derive().expect("derive");
println!(
"joiner sid request={} derive={}",
hex(&sid_j),
hex(&sid_j2)
);
println!("joiner k_ba = {}", hex(&keys_j.joiner_to_initiator[..8]));
println!("joiner nonce = {}", hex(&keys_j.nonce));
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&msg1[..NONCE_LEN]);
let mut eph = [0u8; PUBLIC_KEY_LEN];
eph.copy_from_slice(&msg1[NONCE_LEN..MSG1_HEADER_LEN]);
let eph_pub = PublicKey::from_sec1_bytes(&eph).expect("on the curve");
let sid_i = transcript_hash(
&pending.payload.public_key,
&pending.payload.nonce,
&msg1[NONCE_LEN..MSG1_HEADER_LEN],
&nonce,
);
println!("initiator sid = {}", hex(&sid_i));
println!(
"payload.pub == msg1 header eph? {}",
pending.payload.public_key[..] == eph[..]
);
println!(
"eph_pub == from header? {}",
eph_pub.to_sec1_point(false).as_bytes() == &eph[..]
);
let shared = pending.secret.diffie_hellman(&eph_pub);
let keys_i = SessionKeys::derive(shared.raw_secret_bytes(), &sid_i);
println!("initiator k_ba = {}", hex(&keys_i.joiner_to_initiator[..8]));
println!("initiator nonce = {}", hex(&keys_i.nonce));
println!(
"accept err = {:?}",
pending.accept(&msg1, &MasterKey::generate().unwrap()).err()
);
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn master_key_storage_rejects_wrong_length() {
for len in [0_usize, 31, 33] {
let bytes: SecretBytes = vec![0_u8; len].into();
assert!(
master_key_from_storage(bytes).is_err(),
"length {len} must be rejected"
);
}
}
#[test]
fn debug_output_is_redacted() {
let master = MasterKey::generate().expect("randomness should be available");
let identity = fake_identity();
let (payload, pending) = begin().expect("randomness should be available");
let joiner = join(&payload).expect("randomness should be available");
let (msg1, _) = joiner.request(&identity).expect("Msg1 should be produced");
let joiner_debug = format!("{joiner:?}");
let adoption = pending
.accept(&msg1, &master)
.expect("it should authenticate");
let _ = joiner
.open(adoption.reply(), &identity)
.expect("it should open");
let texts = [
format!("{payload:?}"),
joiner_debug,
format!("{adoption:?}"),
];
for text in &texts {
assert!(
!text.contains("XChaCha"),
"no internal type name should appear"
);
}
let hex: String = master.expose().iter().map(|b| format!("{b:02x}")).collect();
for text in &texts {
assert!(
!text.contains(&hex),
"Debug output leaked the master key in hex: {text}"
);
}
}
#[test]
fn reply_actually_carries_the_master_key() {
let (a, b, _, _) = run_pairing();
assert_eq!(a.expose(), b.expose());
assert_ne!(
a.expose(),
&[0_u8; MASTER_KEY_LEN],
"the master key must not be all zeros"
);
}
}