use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use curve25519_dalek::{RistrettoPoint, Scalar, ristretto::CompressedRistretto, traits::Identity};
use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256, Sha512};
use crate::e2e::SecretKey;
const ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
pub const PLATE_LEN: usize = 3;
const SECRET_LEN: usize = 4;
fn hmac(key: &[u8], label: &str) -> [u8; 32] {
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC takes any key length");
mac.update(label.as_bytes());
mac.finalize().into_bytes().into()
}
pub fn random<const N: usize>() -> Result<[u8; N], String> {
let mut bytes = [0; N];
getrandom::fill(&mut bytes).map_err(|e| format!("cannot get random bytes: {e}"))?;
Ok(bytes)
}
pub fn new_id() -> Result<String, String> {
Ok(URL_SAFE_NO_PAD.encode(random::<16>()?))
}
#[derive(Clone)]
pub struct RoomKey([u8; 32]);
impl RoomKey {
pub fn generate() -> Result<Self, String> {
random().map(Self)
}
pub fn parse(encoded: &str) -> Option<Self> {
let raw = URL_SAFE_NO_PAD.decode(encoded.trim()).ok()?;
Some(Self(raw.try_into().ok()?))
}
pub fn encode(&self) -> String {
URL_SAFE_NO_PAD.encode(self.0)
}
pub fn room_id(&self) -> String {
URL_SAFE_NO_PAD.encode(&hmac(&self.0, "ml-clip room")[..16])
}
pub fn meta_key(&self, clip_id: &str) -> SecretKey {
SecretKey::from_bytes(hmac(&self.0, &format!("ml-clip meta {clip_id}")))
}
pub fn blob_key(&self, clip_id: &str) -> SecretKey {
SecretKey::from_bytes(hmac(&self.0, &format!("ml-clip blob {clip_id}")))
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct Code(String);
impl Code {
pub fn new(plate: &str) -> Result<Self, String> {
let mut secret = String::new();
while secret.len() < SECRET_LEN {
for b in random::<16>()? {
if b < 248 && secret.len() < SECRET_LEN {
secret.push(ALPHABET[(b % 31) as usize] as char);
}
}
}
Self::parse(&format!("{plate}{secret}")).ok_or_else(|| "the server sent a malformed code".into())
}
pub fn parse(input: &str) -> Option<Self> {
let input = input.trim();
let input = input.rsplit_once("#clip=").map_or(input, |(_, c)| c);
let code: String = input
.chars()
.filter(|c| !matches!(c, '-' | ' '))
.map(|c| c.to_ascii_uppercase())
.collect();
(code.len() == PLATE_LEN + SECRET_LEN && code.bytes().all(|b| ALPHABET.contains(&b))).then_some(Self(code))
}
pub fn plate(&self) -> &str {
&self.0[..PLATE_LEN]
}
pub fn display(&self) -> String {
format!("{}-{}", &self.0[..PLATE_LEN], &self.0[PLATE_LEN..])
}
}
pub struct Pake {
scalar: Scalar,
sid: String,
pub public: [u8; 32],
}
pub struct PairKeys {
pub to_joiner: SecretKey,
pub to_host: SecretKey,
}
impl Pake {
pub fn start(code: &Code, sid: &str) -> Result<Self, String> {
Ok(Self::with_scalar(code, sid, &random()?))
}
fn with_scalar(code: &Code, sid: &str, wide: &[u8; 64]) -> Self {
let mut h = Sha512::new();
h.update(b"ml-clip pake v1\0");
h.update(sid.as_bytes());
h.update(b"\0");
h.update(code.0.as_bytes());
let generator = RistrettoPoint::from_uniform_bytes(&h.finalize().into());
let scalar = Scalar::from_bytes_mod_order_wide(wide);
let public = (scalar * generator).compress().to_bytes();
Self {
scalar,
sid: sid.to_string(),
public,
}
}
pub fn finish(self, host: &[u8; 32], joiner: &[u8; 32]) -> Result<PairKeys, String> {
let theirs = if *host == self.public { joiner } else { host };
let point = CompressedRistretto(*theirs)
.decompress()
.filter(|p| *p != RistrettoPoint::identity())
.ok_or("the other device sent a broken pairing message")?;
let shared = (self.scalar * point).compress().to_bytes();
let mut h = Sha256::new();
h.update(b"ml-clip pake v1 key\0");
h.update(self.sid.as_bytes());
h.update(host);
h.update(joiner);
h.update(shared);
let session: [u8; 32] = h.finalize().into();
Ok(PairKeys {
to_joiner: SecretKey::from_bytes(hmac(&session, "ml-clip host to joiner")),
to_host: SecretKey::from_bytes(hmac(&session, "ml-clip joiner to host")),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_parse_loosely_and_print_with_a_dash() {
let c = Code::parse(" k7x-42pq ").unwrap();
assert_eq!(c.display(), "K7X-42PQ");
assert_eq!(c.plate(), "K7X");
assert_eq!(Code::parse("https://m.example.com/#clip=K7X-42PQ"), Some(c.clone()));
assert_eq!(Code::parse("K7X 42PQ"), Some(c));
assert_eq!(Code::parse("K7X-42P"), None);
assert_eq!(Code::parse("K0X-42PQ"), None);
let fresh = Code::new("ABC").unwrap();
assert_eq!(fresh.plate(), "ABC");
assert_eq!(fresh.display().len(), 8);
}
#[test]
fn same_code_gives_same_keys_and_a_wrong_code_does_not() {
let code = Code::parse("K7X-42PQ").unwrap();
let host = Pake::start(&code, "sid").unwrap();
let joiner = Pake::start(&code, "sid").unwrap();
let (h, j) = (host.public, joiner.public);
let a = host.finish(&h, &j).unwrap();
let b = joiner.finish(&h, &j).unwrap();
assert_eq!(a.to_joiner.encode(), b.to_joiner.encode());
assert_eq!(a.to_host.encode(), b.to_host.encode());
assert_ne!(a.to_host.encode(), a.to_joiner.encode());
let host = Pake::start(&code, "sid").unwrap();
let wrong = Pake::start(&Code::parse("K7X-42PR").unwrap(), "sid").unwrap();
let (h, j) = (host.public, wrong.public);
assert_ne!(
host.finish(&h, &j).unwrap().to_joiner.encode(),
wrong.finish(&h, &j).unwrap().to_joiner.encode()
);
}
#[test]
fn pake_matches_the_web_page() {
let pake = Pake::with_scalar(&Code::parse("K7X-42PQ").unwrap(), "sid", &[1; 64]);
let hex: String = pake.public.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(hex, "1efbb3937a660d88c78184718b522253f1d34b7835eb2ba5c505ae87d34cf278");
}
#[test]
fn room_keys_derive_stable_ids_and_keys() {
let key = RoomKey([7; 32]);
assert_eq!(key.room_id().len(), 22);
assert_eq!(key.room_id(), RoomKey([7; 32]).room_id());
assert_ne!(key.meta_key("a").encode(), key.blob_key("a").encode());
assert_ne!(key.meta_key("a").encode(), key.meta_key("b").encode());
assert_eq!(RoomKey::parse(&key.encode()).unwrap().0, key.0);
}
}