soblendwa 0.1.0

SoblendWA: a blazing fast WhatsApp Web multi-device client library written in Rust. Crafted by Soblend Development Studio.
Documentation
use std::time::Duration;

use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use prost::Message as _;

use super::{Client, Outcome};
use crate::binary::Node;
use crate::crypto::{self, verify};
use crate::error::{Error, Result};
use crate::events::Event;
use crate::jid::Jid;
use crate::proto::{AdvDeviceIdentity, AdvSignedDeviceIdentity, AdvSignedDeviceIdentityHmac, ADV_ENCRYPTION_HOSTED};

const CROCKFORD: &[u8; 32] = b"123456789ABCDEFGHJKLMNPQRSTVWXYZ";
const PAIRING_ROUNDS: u32 = 2 << 16;
const FIRST_QR_TTL: Duration = Duration::from_secs(60);
const NEXT_QR_TTL: Duration = Duration::from_secs(20);

fn crockford(bytes: &[u8]) -> String {
    let mut out = String::new();
    let mut value: u64 = 0;
    let mut bits = 0;
    for byte in bytes {
        value = (value << 8) | u64::from(*byte);
        bits += 8;
        while bits >= 5 {
            out.push(char::from(CROCKFORD[((value >> (bits - 5)) & 31) as usize]));
            bits -= 5;
        }
    }
    if bits > 0 {
        out.push(char::from(CROCKFORD[((value << (5 - bits)) & 31) as usize]));
    }
    out
}

impl Client {
    pub(super) async fn on_pair_device(&self, node: &Node) -> Result<()> {
        let mut ack = Node::new("iq").attr("to", Jid::server()).attr("type", "result");
        if let Some(id) = node.get("id") {
            ack.set_attr("id", id.clone());
        }
        self.send_node(&ack)?;

        let refs: Vec<String> = node
            .child("pair-device")
            .map(|p| p.children_by("ref").filter_map(Node::content_string).collect())
            .unwrap_or_default();
        let creds = self.creds();
        let noise = STANDARD.encode(creds.noise_key.public);
        let identity = STANDARD.encode(creds.identity_key.public);
        let adv = STANDARD.encode(creds.adv_secret_key);
        let platform = self.inner.config.platform.companion_id();
        let generation = self.current_generation();

        let client = self.clone();
        tokio::spawn(async move {
            for (index, reference) in refs.into_iter().enumerate() {
                if client.current_generation() != generation || client.is_logged_in() {
                    return;
                }
                let code =
                    format!("https://wa.me/settings/linked_devices#{reference},{noise},{identity},{adv},{platform}");
                client.emit(Event::Qr(code));
                tokio::time::sleep(if index == 0 { FIRST_QR_TTL } else { NEXT_QR_TTL }).await;
            }
            if client.current_generation() == generation && !client.is_logged_in() {
                client.end_link(Outcome::QrTimeout);
            }
        });
        Ok(())
    }

    pub(super) async fn on_pair_success(&self, node: &Node) -> Result<()> {
        let id = node.attr_string("id").unwrap_or_default();
        match self.complete_pairing(node).await {
            Ok(reply) => {
                let reply = Node::new("iq")
                    .attr("to", Jid::server())
                    .attr("type", "result")
                    .attr("id", id)
                    .child_node(Node::new("pair-device-sign").child_node(reply));
                self.send_node(&reply)
            }
            Err(err) => {
                let reject = Node::new("iq")
                    .attr("to", Jid::server())
                    .attr("type", "error")
                    .attr("id", id)
                    .child_node(Node::new("error").attr("code", "401").attr("text", "not-authorized"));
                let _ = self.send_node(&reject);
                self.end_link(Outcome::Lost(format!("pairing failed: {err}")));
                Err(err)
            }
        }
    }

    async fn complete_pairing(&self, node: &Node) -> Result<Node> {
        let invalid = |why: &str| Error::Handshake(format!("pair-success rejected: {why}"));
        let pair = node.child("pair-success").ok_or_else(|| invalid("missing body"))?;
        let identity_blob = pair.child_bytes("device-identity").ok_or_else(|| invalid("missing device identity"))?;
        let device = pair.child("device").ok_or_else(|| invalid("missing device"))?;
        let jid = device.attr_jid("jid").ok_or_else(|| invalid("missing jid"))?;
        let lid = device.attr_jid("lid");
        let platform = pair.child("platform").and_then(|p| p.attr_string("name"));
        let business_name = pair.child("biz").and_then(|b| b.attr_string("name"));

        let creds = self.creds();
        let container = AdvSignedDeviceIdentityHmac::decode(identity_blob)?;
        let details = container.details.unwrap_or_default();
        let hosted = container.account_type == Some(ADV_ENCRYPTION_HOSTED);
        let prefix: &[u8] = if hosted { &[6, 5] } else { &[] };
        let expected = crypto::hmac_sha256(&creds.adv_secret_key, &[prefix, &details]);
        if !crypto::constant_time_eq(&expected, container.hmac.as_deref().unwrap_or_default()) {
            return Err(invalid("hmac mismatch"));
        }

        let mut account = AdvSignedDeviceIdentity::decode(details.as_slice())?;
        let device_details = account.details.clone().unwrap_or_default();
        let identity = AdvDeviceIdentity::decode(device_details.as_slice())?;
        let account_key: [u8; 32] = account
            .account_signature_key
            .as_deref()
            .and_then(|k| k.try_into().ok())
            .ok_or_else(|| invalid("missing account signature key"))?;
        let account_signature: [u8; 64] = account
            .account_signature
            .as_deref()
            .and_then(|s| s.try_into().ok())
            .ok_or_else(|| invalid("missing account signature"))?;

        let account_prefix: &[u8] = if identity.device_type == Some(ADV_ENCRYPTION_HOSTED) { &[6, 5] } else { &[6, 0] };
        let account_message = [account_prefix, &device_details, &creds.identity_key.public].concat();
        if !verify(&account_key, &account_message, &account_signature) {
            return Err(invalid("account signature does not verify"));
        }

        let device_message = [&[6u8, 1][..], &device_details, &creds.identity_key.public, &account_key].concat();
        account.device_signature = Some(creds.identity_key.sign(&device_message).to_vec());

        let stored = account.encode_to_vec();
        let mut reply_account = account.clone();
        reply_account.account_signature_key = None;

        self.update_creds(|c| {
            c.me = Some(jid.clone());
            c.lid = lid.clone();
            c.platform = platform.clone();
            c.business_name = business_name.clone();
            c.account = Some(stored);
            c.registered = true;
            c.pairing_code = None;
        })
        .await?;
        self.emit(Event::PairSuccess { id: jid, lid, platform, business_name });

        Ok(Node::new("device-identity")
            .attr("key-index", identity.key_index.unwrap_or_default())
            .bytes(reply_account.encode_to_vec()))
    }

    pub async fn request_pair_code(&self, phone: &str) -> Result<String> {
        if self.is_logged_in() {
            return Err(Error::other("this session is already paired"));
        }
        let me = Jid::phone(phone);
        if me.user.is_empty() {
            return Err(Error::InvalidJid(phone.to_owned()));
        }
        let code = crockford(&crypto::random_array::<5>());
        let creds = self.creds();

        let salt = crypto::random_array::<32>();
        let iv = crypto::random_array::<16>();
        let key = derive_pairing_key(&code, &salt).await?;
        let wrapped = [&salt[..], &iv, &crypto::aes_ctr(&key, &iv, &creds.pairing_ephemeral_key.public)?].concat();

        self.update_creds(|c| {
            c.pairing_code = Some(code.clone());
            c.me = Some(me.clone());
        })
        .await?;

        let platform = self.inner.config.platform;
        let display = format!("{} ({})", platform.display_name(), self.inner.config.device_name);
        let request = Node::new("iq").attr("to", Jid::server()).attr("type", "set").attr("xmlns", "md").child_node(
            Node::new("link_code_companion_reg")
                .attr("jid", me)
                .attr("stage", "companion_hello")
                .attr("should_show_push_notification", "true")
                .children([
                    Node::new("link_code_pairing_wrapped_companion_ephemeral_pub").bytes(wrapped),
                    Node::new("companion_server_auth_key_pub").bytes(creds.noise_key.public.to_vec()),
                    Node::new("companion_platform_id").text(platform.companion_id().to_string()),
                    Node::new("companion_platform_display").text(display),
                    Node::new("link_code_pairing_nonce").text("0"),
                ]),
        );
        self.query(request).await?;
        Ok(code)
    }

    pub(super) async fn on_link_code_notification(&self, node: &Node) -> Result<()> {
        let missing = |what: &str| Error::Handshake(format!("link code notification is missing {what}"));
        let registration = node.child("link_code_companion_reg").ok_or_else(|| missing("registration"))?;
        let reference = registration.child_bytes("link_code_pairing_ref").ok_or_else(|| missing("ref"))?.to_vec();
        let primary_identity: [u8; 32] = registration
            .child_bytes("primary_identity_pub")
            .and_then(|k| k.try_into().ok())
            .ok_or_else(|| missing("primary identity"))?;
        let wrapped = registration
            .child_bytes("link_code_pairing_wrapped_primary_ephemeral_pub")
            .filter(|w| w.len() >= 80)
            .ok_or_else(|| missing("primary ephemeral key"))?;

        let creds = self.creds();
        let code = creds.pairing_code.clone().ok_or_else(|| missing("a pending pairing code"))?;
        let me = creds.me.clone().ok_or_else(|| missing("the phone number"))?;

        let key = derive_pairing_key(&code, &wrapped[..32]).await?;
        let primary_ephemeral: [u8; 32] = crypto::aes_ctr(&key, &wrapped[32..48], &wrapped[48..80])?
            .try_into()
            .map_err(|_| missing("a valid primary ephemeral key"))?;

        let companion_shared = creds.pairing_ephemeral_key.shared_secret(&primary_ephemeral);
        let random = crypto::random_array::<32>();
        let bundle_salt = crypto::random_array::<32>();
        let bundle_key: [u8; 32] =
            crypto::hkdf_array(&companion_shared, Some(&bundle_salt), b"link_code_pairing_key_bundle_encryption_key");
        let bundle_iv = crypto::random_array::<12>();
        let bundle_plain = [&creds.identity_key.public[..], &primary_identity, &random].concat();
        let bundle = crypto::aes_gcm_encrypt(&bundle_key, &bundle_iv, &[], &bundle_plain)?;
        let wrapped_bundle = [&bundle_salt[..], &bundle_iv, &bundle].concat();

        let identity_shared = creds.identity_key.shared_secret(&primary_identity);
        let adv_input = [&companion_shared[..], &identity_shared, &random].concat();
        let adv_secret: [u8; 32] = crypto::hkdf_array(&adv_input, None, b"adv_secret");
        self.update_creds(|c| c.adv_secret_key = adv_secret).await?;

        let finish = Node::new("iq").attr("to", Jid::server()).attr("type", "set").attr("xmlns", "md").child_node(
            Node::new("link_code_companion_reg").attr("jid", me).attr("stage", "companion_finish").children([
                Node::new("link_code_pairing_wrapped_key_bundle").bytes(wrapped_bundle),
                Node::new("companion_identity_public").bytes(creds.identity_key.public.to_vec()),
                Node::new("link_code_pairing_ref").bytes(reference),
            ]),
        );
        self.query(finish).await?;
        Ok(())
    }
}

async fn derive_pairing_key(code: &str, salt: &[u8]) -> Result<[u8; 32]> {
    let code = code.as_bytes().to_vec();
    let salt = salt.to_vec();
    tokio::task::spawn_blocking(move || crypto::pbkdf2_sha256(&code, &salt, PAIRING_ROUNDS))
        .await
        .map_err(|err| Error::other(err.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn crockford_produces_eight_chars() {
        let code = crockford(&[0xff, 0x00, 0xaa, 0x55, 0x12]);
        assert_eq!(code.len(), 8);
        assert!(code.bytes().all(|c| CROCKFORD.contains(&c)));
        assert_eq!(crockford(&[0, 0, 0, 0, 0]), "11111111");
    }
}