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::path::Path;

use prost::Message as _;
use serde_json::Value;

use super::{Creds, KeyKind, SignedPreKey, Store};
use crate::crypto::{strip_key_prefix, KeyPair};
use crate::error::{Error, Result};
use crate::jid::Jid;
use crate::proto::{AdvSignedDeviceIdentity, StoredHashEntry, StoredHashState};
use crate::signal::{import_baileys_sender_key, import_libsignal_session, json_bytes};

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BaileysImport {
    pub me: Option<Jid>,
    pub sessions: usize,
    pub pre_keys: usize,
    pub sender_keys: usize,
    pub app_state_keys: usize,
    pub app_state_versions: usize,
    pub lid_mappings: usize,
    pub skipped: Vec<String>,
    pub failed: Vec<(String, String)>,
}

fn invalid(what: &str) -> Error {
    Error::Store(format!("baileys creds.json: {what}"))
}

fn bytes(value: &Value, what: &str) -> Result<Vec<u8>> {
    json_bytes(value).ok_or_else(|| invalid(&format!("{what} is missing or unreadable")))
}

fn key32(value: &Value, what: &str) -> Result<[u8; 32]> {
    let raw = bytes(value, what)?;
    match raw.len() {
        33 => strip_key_prefix(&raw),
        _ => raw.as_slice().try_into().map_err(|_| invalid(&format!("{what} has {} bytes instead of 32", raw.len()))),
    }
}

fn key_pair(value: &Value, what: &str) -> Result<KeyPair> {
    let pair = KeyPair::from_private(key32(&value["private"], what)?);
    if let Ok(public) = key32(&value["public"], what) {
        if public != pair.public {
            return Err(invalid(&format!("{what} public key does not match its private key")));
        }
    }
    Ok(pair)
}

fn number(value: &Value) -> Option<u64> {
    value.as_u64().or_else(|| value.as_str()?.parse().ok())
}

fn optional_bytes(value: &Value) -> Option<Vec<u8>> {
    (!value.is_null()).then(|| json_bytes(value)).flatten()
}

fn plain_user(text: &str) -> String {
    let user = text.split('@').next().unwrap_or(text);
    user.split(':').next().unwrap_or(user).to_owned()
}

pub fn creds_from_baileys(value: &Value) -> Result<Creds> {
    let identity_key = key_pair(&value["signedIdentityKey"], "signedIdentityKey")?;
    let signed = &value["signedPreKey"];
    let signature: [u8; 64] = bytes(&signed["signature"], "signedPreKey.signature")?
        .try_into()
        .map_err(|_| invalid("signedPreKey.signature must have 64 bytes"))?;
    let signed_pre_key = SignedPreKey {
        id: number(&signed["keyId"]).ok_or_else(|| invalid("signedPreKey.keyId is missing"))? as u32,
        key_pair: key_pair(&signed["keyPair"], "signedPreKey.keyPair")?,
        signature,
    };
    let adv_secret_key: [u8; 32] = bytes(&value["advSecretKey"], "advSecretKey")?
        .try_into()
        .map_err(|_| invalid("advSecretKey must have 32 bytes"))?;
    let account = value.get("account").filter(|a| a.is_object()).map(|account| {
        AdvSignedDeviceIdentity {
            details: optional_bytes(&account["details"]),
            account_signature_key: optional_bytes(&account["accountSignatureKey"]),
            account_signature: optional_bytes(&account["accountSignature"]),
            device_signature: optional_bytes(&account["deviceSignature"]),
        }
        .encode_to_vec()
    });
    let me = &value["me"];
    let parse_jid = |field: &str| me[field].as_str().and_then(|j| j.parse::<Jid>().ok());
    let mut creds = Creds {
        noise_key: key_pair(&value["noiseKey"], "noiseKey")?,
        pairing_ephemeral_key: key_pair(&value["pairingEphemeralKeyPair"], "pairingEphemeralKeyPair")
            .unwrap_or_else(|_| KeyPair::generate()),
        identity_key,
        signed_pre_key,
        registration_id: number(&value["registrationId"]).ok_or_else(|| invalid("registrationId is missing"))? as u32,
        adv_secret_key,
        next_pre_key_id: number(&value["nextPreKeyId"]).unwrap_or(1) as u32,
        first_unuploaded_pre_key_id: number(&value["firstUnuploadedPreKeyId"]).unwrap_or(1) as u32,
        me: parse_jid("id"),
        lid: parse_jid("lid"),
        push_name: me["name"].as_str().map(str::to_owned),
        registered: account.is_some(),
        account,
        platform: value["platform"].as_str().map(str::to_owned),
        business_name: None,
        pairing_code: value["pairingCode"].as_str().map(str::to_owned),
        routing_info: optional_bytes(&value["routingInfo"]),
        wa_version: None,
        app_state_key_id: value["myAppStateKeyId"].as_str().map(str::to_owned),
        signed_pre_key_rotated_at: crate::message::now_secs(),
    };
    if creds.next_pre_key_id < creds.first_unuploaded_pre_key_id {
        creds.next_pre_key_id = creds.first_unuploaded_pre_key_id;
    }
    Ok(creds)
}

fn hash_state(value: &Value) -> Result<Vec<u8>> {
    let hash = bytes(&value["hash"], "app state hash")?;
    if hash.len() != 128 {
        return Err(Error::Store("app state hash must have 128 bytes".into()));
    }
    let entries = value["indexValueMap"]
        .as_object()
        .into_iter()
        .flatten()
        .filter_map(|(index, entry)| {
            Some(StoredHashEntry {
                index_mac: json_bytes(&Value::String(index.clone()))?,
                value_mac: json_bytes(&entry["valueMac"])?,
            })
        })
        .collect();
    Ok(StoredHashState { version: number(&value["version"]).unwrap_or(0), hash, entries }.encode_to_vec())
}

fn file_id(name: &str) -> String {
    name.replace("__", "/")
}

pub async fn import_baileys(dir: impl AsRef<Path>, store: &dyn Store) -> Result<BaileysImport> {
    let dir = dir.as_ref();
    let creds_path = dir.join("creds.json");
    let raw = tokio::fs::read(&creds_path)
        .await
        .map_err(|err| Error::Store(format!("cannot read {}: {err}", creds_path.display())))?;
    let creds = creds_from_baileys(&serde_json::from_slice(&raw)?)?;
    let mut report = BaileysImport { me: creds.me.clone(), ..Default::default() };

    let mut entries = tokio::fs::read_dir(dir).await?;
    let mut files = Vec::new();
    while let Some(entry) = entries.next_entry().await? {
        let name = entry.file_name().to_string_lossy().into_owned();
        if name != "creds.json" && name.ends_with(".json") {
            files.push((name, entry.path()));
        }
    }
    files.sort();

    for (name, path) in files {
        let stem = name.trim_end_matches(".json");
        let outcome = async {
            let value: Value = serde_json::from_slice(&tokio::fs::read(&path).await?)?;
            if let Some(id) = stem.strip_prefix("pre-key-") {
                let pair = key_pair(&value, &name)?;
                store.set(KeyKind::PreKey, id, pair.private.to_vec()).await?;
                report.pre_keys += 1;
            } else if let Some(address) = stem.strip_prefix("session-") {
                let record = match &value {
                    Value::Object(map) if map.contains_key("_sessions") => value.clone(),
                    other => serde_json::from_slice(&bytes(other, &name)?)?,
                };
                store.set(KeyKind::Session, &file_id(address), import_libsignal_session(&record)?).await?;
                report.sessions += 1;
            } else if stem.starts_with("sender-key-memory-") {
                report.skipped.push(name.clone());
            } else if let Some(id) = stem.strip_prefix("sender-key-") {
                let mut parts = id.rsplitn(3, "--");
                let (Some(device), Some(sender), Some(group)) = (parts.next(), parts.next(), parts.next()) else {
                    return Err(Error::Store("unexpected sender key file name".into()));
                };
                let key = format!("{}::{sender}.{device}", file_id(group));
                store.set(KeyKind::SenderKey, &key, import_baileys_sender_key(&value)?).await?;
                report.sender_keys += 1;
            } else if let Some(id) = stem.strip_prefix("app-state-sync-key-") {
                let data = value.get("keyData").map(|k| bytes(k, &name)).unwrap_or_else(|| bytes(&value, &name))?;
                store.set(KeyKind::AppStateKey, &file_id(id), data).await?;
                report.app_state_keys += 1;
            } else if let Some(collection) = stem.strip_prefix("app-state-sync-version-") {
                store.set(KeyKind::AppStateVersion, collection, hash_state(&value)?).await?;
                report.app_state_versions += 1;
            } else if let Some(id) = stem.strip_prefix("lid-mapping-") {
                let target =
                    value.as_str().map(plain_user).ok_or_else(|| Error::Store("lid mapping is not a string".into()))?;
                match id.strip_suffix("_reverse") {
                    Some(lid) => {
                        store.set(KeyKind::LidMapping, &format!("lid:{}", plain_user(lid)), target.into_bytes()).await?
                    }
                    None => {
                        store.set(KeyKind::LidMapping, &format!("pn:{}", plain_user(id)), target.into_bytes()).await?
                    }
                }
                report.lid_mappings += 1;
            } else {
                report.skipped.push(name.clone());
            }
            Ok::<_, Error>(())
        }
        .await;
        if let Err(err) = outcome {
            report.failed.push((name, err.to_string()));
        }
    }

    store.save_creds(&creds).await?;
    store.flush().await?;
    Ok(report)
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::sync::Arc;

    use super::*;
    use crate::signal::{EncType, SignalRepository};
    use crate::store::MemoryStore;

    fn fixtures() -> PathBuf {
        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures")
    }

    async fn import_and_check(auth: &Path, expected: &Value) -> (SignalRepository, String) {
        let store = Arc::new(MemoryStore::new());
        let report = import_baileys(auth, store.as_ref()).await.unwrap();
        assert!(report.failed.is_empty(), "{:?}", report.failed);
        assert_eq!(
            (report.sessions, report.pre_keys, report.sender_keys, report.app_state_keys, report.app_state_versions),
            (1, 2, 1, 1, 1)
        );
        assert_eq!(report.lid_mappings, 2);
        assert_eq!(report.skipped.len(), 2);

        let creds = store.load_creds().await.unwrap().unwrap();
        assert_eq!(creds.me.as_ref().map(Jid::to_string).as_deref(), Some("5215511111111:7@s.whatsapp.net"));
        assert_eq!(creds.lid.as_ref().map(Jid::to_string).as_deref(), Some("111111111111111:7@lid"));
        assert_eq!((creds.registration_id, creds.push_name.as_deref()), (4321, Some("Alice Baileys")));
        assert!(creds.is_logged_in());
        let account = AdvSignedDeviceIdentity::decode(creds.account.as_deref().unwrap()).unwrap();
        assert_eq!(account.account_signature_key.map(|k| k.len()), Some(32));
        assert!(crate::crypto::verify(
            &creds.identity_key.public,
            &creds.signed_pre_key.key_pair.signal_public(),
            &creds.signed_pre_key.signature
        ));

        let key_id = expected["appStateKeyId"].as_str().unwrap();
        let stored_key = store.get(KeyKind::AppStateKey, key_id).await.unwrap().unwrap();
        assert_eq!(hex::encode(stored_key), expected["appStateKey"].as_str().unwrap());
        let lid = store.get(KeyKind::LidMapping, "pn:5215522222222").await.unwrap().unwrap();
        assert_eq!(lid, b"222222222222222");
        assert!(store.get(KeyKind::PreKey, "2").await.unwrap().is_some());

        let repo = SignalRepository::new(store.clone(), &creds);
        let bob = expected["bob"].as_str().unwrap().to_owned();
        for pending in expected["pending"].as_array().unwrap() {
            let body = hex::decode(pending["body"].as_str().unwrap()).unwrap();
            let plain = repo.decrypt(&bob, EncType::Message, &body).await.unwrap();
            assert_eq!(String::from_utf8(plain).unwrap(), pending["text"].as_str().unwrap());
        }
        let group = &expected["groupMessage"];
        let plain = repo
            .group_decrypt(
                expected["group"].as_str().unwrap(),
                expected["groupSender"].as_str().unwrap(),
                &hex::decode(group["body"].as_str().unwrap()).unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(String::from_utf8(plain).unwrap(), group["text"].as_str().unwrap());
        (repo, bob)
    }

    #[tokio::test]
    async fn imports_a_real_baileys_folder() {
        let expected: Value =
            serde_json::from_slice(&std::fs::read(fixtures().join("baileys-expected.json")).unwrap()).unwrap();
        let (repo, bob) = import_and_check(&fixtures().join("baileys-auth"), &expected).await;
        let (kind, _) = repo.encrypt(&bob, b"respuesta desde soblendwa").await.unwrap();
        assert_eq!(kind, EncType::Message);
    }

    #[tokio::test]
    #[ignore = "set SOBLEND_BAILEYS_WORK to a folder produced by the libsignal export script"]
    async fn answers_libsignal_after_importing() {
        let work = PathBuf::from(std::env::var("SOBLEND_BAILEYS_WORK").unwrap());
        let expected: Value = serde_json::from_slice(&std::fs::read(work.join("expected.json")).unwrap()).unwrap();
        let (repo, bob) = import_and_check(&work.join("auth"), &expected).await;
        let mut replies = Vec::new();
        for text in ["primera respuesta de soblendwa", "segunda respuesta de soblendwa"] {
            let (kind, body) = repo.encrypt(&bob, text.as_bytes()).await.unwrap();
            replies.push(serde_json::json!({ "type": kind.as_str(), "body": hex::encode(body), "text": text }));
        }
        std::fs::write(work.join("rust_replies.json"), serde_json::to_vec_pretty(&replies).unwrap()).unwrap();
    }

    #[test]
    fn reads_every_buffer_encoding_baileys_produces() {
        let expected = vec![5u8, 0, 255];
        for value in [
            serde_json::json!("BQD/"),
            serde_json::json!([5, 0, 255]),
            serde_json::json!({ "type": "Buffer", "data": "BQD/" }),
            serde_json::json!({ "type": "Buffer", "data": [5, 0, 255] }),
            serde_json::json!({ "0": 5, "1": 0, "2": 255 }),
        ] {
            assert_eq!(json_bytes(&value), Some(expected.clone()), "{value}");
        }
        assert_eq!(json_bytes(&serde_json::json!({ "1": 5 })), None);
        assert_eq!(plain_user("123456:7@lid"), "123456");
    }
}