use argon2::{Algorithm, Argon2, Version};
use hkdf::Hkdf;
use sha2::{Digest, Sha256};
use x25519_dalek::{PublicKey, StaticSecret};
use crate::crypto::passphrase::{argon2id_params, KEY_LEN};
use crate::error::{ProtocolError, Result};
const CODE_JOIN_INFO: &[u8] = b"huddle-code-join-v1";
pub fn derive_wrap_key(our_secret: &StaticSecret, their_pub: &PublicKey) -> Result<[u8; KEY_LEN]> {
let shared = our_secret.diffie_hellman(their_pub);
if !shared.was_contributory() {
return Err(ProtocolError::Session(
"code-join key agreement rejected: peer X25519 pubkey is non-contributory \
(small-order point)"
.into(),
));
}
let hk = Hkdf::<Sha256>::new(None, shared.as_bytes());
let mut wrap_key = [0u8; KEY_LEN];
hk.expand(CODE_JOIN_INFO, &mut wrap_key)
.expect("32 bytes is within HKDF-SHA256's output limit");
Ok(wrap_key)
}
const CODE_PROOF_INFO: &[u8] = b"huddle-code-join-proof-v2";
pub const CODE_JOIN_V2_PREFIX: &str = "v2-";
pub fn derive_code_proof(
code: &str,
room_id: &str,
joiner_x25519_pub: &[u8; 32],
) -> Result<[u8; 32]> {
let mut h = Sha256::new();
h.update(CODE_PROOF_INFO);
h.update(room_id.as_bytes());
h.update(joiner_x25519_pub);
let digest = h.finalize();
let salt = &digest[..16];
let params = argon2id_params(32)?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut out = [0u8; 32];
argon
.hash_password_into(code.as_bytes(), salt, &mut out)
.map_err(|e| ProtocolError::Session(format!("code-proof argon2: {e}")))?;
Ok(out)
}
pub fn verify_code_proof(
expected_code: &str,
room_id: &str,
joiner_x25519_pub: &[u8; 32],
proof: &[u8; 32],
) -> Result<bool> {
let recomputed = derive_code_proof(expected_code, room_id, joiner_x25519_pub)?;
Ok(ct_eq_32(&recomputed, proof))
}
#[inline]
fn ct_eq_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
let mut diff = 0u8;
for i in 0..32 {
diff |= a[i] ^ b[i];
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::OsRng;
#[test]
fn both_sides_derive_the_same_wrap_key() {
let owner = StaticSecret::random_from_rng(OsRng);
let joiner = StaticSecret::random_from_rng(OsRng);
let owner_pub = PublicKey::from(&owner);
let joiner_pub = PublicKey::from(&joiner);
let k_owner = derive_wrap_key(&owner, &joiner_pub).unwrap();
let k_joiner = derive_wrap_key(&joiner, &owner_pub).unwrap();
assert_eq!(k_owner, k_joiner, "ECDH is commutative -> same wrap key");
}
#[test]
fn different_peers_derive_different_keys() {
let owner = StaticSecret::random_from_rng(OsRng);
let a = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
let b = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
assert_ne!(
derive_wrap_key(&owner, &a).unwrap(),
derive_wrap_key(&owner, &b).unwrap()
);
}
#[test]
fn code_proof_round_trips_for_the_right_code_and_pubkey() {
let joiner = StaticSecret::random_from_rng(OsRng);
let joiner_pub = PublicKey::from(&joiner);
let proof = derive_code_proof("ABCD-2345", "room-x", joiner_pub.as_bytes()).unwrap();
assert!(
verify_code_proof("ABCD-2345", "room-x", joiner_pub.as_bytes(), &proof).unwrap(),
"the issuing owner re-derives the same proof from the code it handed out"
);
}
#[test]
fn code_proof_rejects_wrong_code() {
let joiner_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
let proof = derive_code_proof("ABCD-2345", "room-x", joiner_pub.as_bytes()).unwrap();
assert!(!verify_code_proof("WXYZ-9876", "room-x", joiner_pub.as_bytes(), &proof).unwrap());
}
#[test]
fn code_proof_is_bound_to_the_joiner_ephemeral_key() {
let real_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
let relay_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
let proof = derive_code_proof("ABCD-2345", "room-x", real_pub.as_bytes()).unwrap();
assert!(
!verify_code_proof("ABCD-2345", "room-x", relay_pub.as_bytes(), &proof).unwrap(),
"a proof for the real joiner's key must fail under the relay's substituted key"
);
}
#[test]
fn v2_prefix_cannot_collide_with_a_legacy_code() {
const LEGACY_ALPHABET: &[u8] = b"ABCDEFGHJKMNPQRSTUVWXYZ23456789";
let first = CODE_JOIN_V2_PREFIX.as_bytes()[0];
assert!((first as char).is_ascii_lowercase());
assert!(!LEGACY_ALPHABET.contains(&first));
assert!(!"ABCD-2345".starts_with(CODE_JOIN_V2_PREFIX));
assert!("v2-ABCD-2345".starts_with(CODE_JOIN_V2_PREFIX));
}
#[test]
fn code_proof_is_bound_to_the_room() {
let joiner_pub = PublicKey::from(&StaticSecret::random_from_rng(OsRng));
let proof = derive_code_proof("ABCD-2345", "room-a", joiner_pub.as_bytes()).unwrap();
assert!(
!verify_code_proof("ABCD-2345", "room-b", joiner_pub.as_bytes(), &proof).unwrap(),
"no cross-room proof replay"
);
}
#[test]
fn rejects_small_order_peer_pubkey() {
let owner = StaticSecret::random_from_rng(OsRng);
let small_order = PublicKey::from([0u8; 32]);
assert!(derive_wrap_key(&owner, &small_order).is_err());
}
}