whatsapp-rust-sqlite-storage 0.7.0

SQLite storage backend for whatsapp-rust
Documentation
//! On-disk encoding for the BLOB columns this backend persists (server cert
//! chain, app-state sync keys, app-state hash state).
//!
//! Modeled as protobuf (`proto/wire.proto`, generated by `buffa` at build
//! time). The format is field-tagged, so reordering or adding a field doesn't
//! corrupt old rows the way a positional codec would, and it stays
//! wire-compatible with rows previously written by the prost-derived
//! equivalents (same field numbers, same proto3 semantics). Domain types in
//! `wacore` stay untouched; conversion happens only at this boundary.

// Crate-local wire protos: this file is their sole instantiation site, so
// direct buffa calls duplicate nothing and there is no waproto::codec home.
#![allow(clippy::disallowed_methods)]

use buffa::Message as _;
use wacore::appstate::hash::HashState;
use wacore::store::device::{CachedNoiseCert, CachedServerCertChain};
use wacore::store::error::StoreError;
use wacore::store::traits::AppStateSyncKey;

mod proto {
    #![allow(
        unused,
        non_camel_case_types,
        non_snake_case,
        unreachable_patterns,
        clippy::all,
        clippy::pedantic,
        clippy::nursery
    )]
    buffa::include_proto!("wire");
}
use proto::{AppStateSyncKeyWire, HashStateWire, NoiseCert, ServerCertChain};

/// X25519 public key length in `CachedNoiseCert`.
const NOISE_KEY_LEN: usize = 32;
/// App-state hash length in `HashState`.
const HASH_STATE_LEN: usize = 128;
/// App-state master key length (the HKDF input for `expand_app_state_keys`).
const APP_STATE_KEY_LEN: usize = 32;

fn bad_len(field: &str, expected: usize, got: usize) -> StoreError {
    StoreError::Serialization(format!("{field}: expected {expected} bytes, got {got}").into())
}

fn decode_err(e: buffa::DecodeError) -> StoreError {
    StoreError::Serialization(Box::new(e))
}

// --- server cert chain ---

impl From<&CachedNoiseCert> for NoiseCert {
    fn from(c: &CachedNoiseCert) -> Self {
        Self {
            key: c.key.to_vec(),
            not_before: c.not_before,
            not_after: c.not_after,
        }
    }
}

fn noise_cert_from_wire(w: NoiseCert) -> Result<CachedNoiseCert, StoreError> {
    let got = w.key.len();
    let key: [u8; NOISE_KEY_LEN] = w
        .key
        .try_into()
        .map_err(|_| bad_len("noise_cert.key", NOISE_KEY_LEN, got))?;
    Ok(CachedNoiseCert {
        key,
        not_before: w.not_before,
        not_after: w.not_after,
    })
}

pub(crate) fn encode_server_cert_chain(c: &CachedServerCertChain) -> Vec<u8> {
    ServerCertChain {
        intermediate: buffa::MessageField::some(NoiseCert::from(&c.intermediate)),
        leaf: buffa::MessageField::some(NoiseCert::from(&c.leaf)),
    }
    .encode_to_vec()
}

pub(crate) fn decode_server_cert_chain(bytes: &[u8]) -> Result<CachedServerCertChain, StoreError> {
    let w = ServerCertChain::decode_from_slice(bytes).map_err(decode_err)?;
    let intermediate = w.intermediate.ok_or_else(|| {
        StoreError::Serialization("server_cert_chain.intermediate missing".into())
    })?;
    let leaf = w
        .leaf
        .ok_or_else(|| StoreError::Serialization("server_cert_chain.leaf missing".into()))?;
    Ok(CachedServerCertChain {
        intermediate: noise_cert_from_wire(intermediate)?,
        leaf: noise_cert_from_wire(leaf)?,
    })
}

// --- app-state sync key ---

pub(crate) fn encode_app_state_sync_key(k: &AppStateSyncKey) -> Vec<u8> {
    AppStateSyncKeyWire {
        key_data: k.key_data.clone(),
        fingerprint: k.fingerprint.clone(),
        timestamp: k.timestamp,
    }
    .encode_to_vec()
}

pub(crate) fn decode_app_state_sync_key(bytes: &[u8]) -> Result<AppStateSyncKey, StoreError> {
    let w = AppStateSyncKeyWire::decode_from_slice(bytes).map_err(decode_err)?;
    // An old bincode row (or a corrupt blob) can occasionally parse as protobuf
    // with garbage key material. Reject anything that isn't a 32-byte master
    // key so the caller treats it as absent and re-requests it, rather than
    // deriving bad sub-keys that later fail with MAC/decrypt errors.
    if w.key_data.len() != APP_STATE_KEY_LEN {
        return Err(bad_len(
            "app_state_sync_key.key_data",
            APP_STATE_KEY_LEN,
            w.key_data.len(),
        ));
    }
    Ok(AppStateSyncKey {
        key_data: w.key_data,
        fingerprint: w.fingerprint,
        timestamp: w.timestamp,
    })
}

// --- app-state hash state ---

pub(crate) fn encode_hash_state(s: &HashState) -> Vec<u8> {
    HashStateWire {
        version: s.version,
        hash: s.hash.to_vec(),
        index_value_map: s
            .index_value_map
            .iter()
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect(),
        mac_mismatch_fatal: s.mac_mismatch_fatal,
    }
    .encode_to_vec()
}

pub(crate) fn decode_hash_state(bytes: &[u8]) -> Result<HashState, StoreError> {
    let w = HashStateWire::decode_from_slice(bytes).map_err(decode_err)?;
    let got = w.hash.len();
    let hash: [u8; HASH_STATE_LEN] = w
        .hash
        .try_into()
        .map_err(|_| bad_len("hash_state.hash", HASH_STATE_LEN, got))?;
    Ok(HashState {
        version: w.version,
        hash,
        index_value_map: w.index_value_map.into_iter().collect(),
        mac_mismatch_fatal: w.mac_mismatch_fatal,
    })
}

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

    #[test]
    fn server_cert_chain_roundtrips() {
        let chain = CachedServerCertChain {
            intermediate: CachedNoiseCert {
                key: [0xAB; 32],
                not_before: 1_700_000_000,
                not_after: 1_900_000_000,
            },
            leaf: CachedNoiseCert {
                key: [0xCD; 32],
                not_before: 1_700_000_500,
                not_after: 1_899_999_500,
            },
        };
        let decoded = decode_server_cert_chain(&encode_server_cert_chain(&chain)).unwrap();
        assert_eq!(decoded, chain);
    }

    #[test]
    fn server_cert_chain_rejects_wrong_key_len() {
        // A wire blob whose key is not 32 bytes must error, not silently truncate.
        let bytes = ServerCertChain {
            intermediate: buffa::MessageField::some(NoiseCert {
                key: vec![0u8; 5],
                not_before: 1,
                not_after: 2,
            }),
            leaf: buffa::MessageField::some(NoiseCert {
                key: vec![0u8; 32],
                not_before: 1,
                not_after: 2,
            }),
        }
        .encode_to_vec();
        assert!(decode_server_cert_chain(&bytes).is_err());
    }

    #[test]
    fn app_state_sync_key_roundtrips() {
        let key = AppStateSyncKey {
            key_data: vec![7u8; 32],
            fingerprint: vec![9, 8, 7],
            timestamp: 1_700_000_123,
        };
        let decoded = decode_app_state_sync_key(&encode_app_state_sync_key(&key)).unwrap();
        assert_eq!(decoded.key_data, key.key_data);
        assert_eq!(decoded.fingerprint, key.fingerprint);
        assert_eq!(decoded.timestamp, key.timestamp);
    }

    #[test]
    fn app_state_sync_key_rejects_wrong_key_len() {
        // An old bincode row can parse as protobuf with garbage key material;
        // non-32-byte key data must error so it is treated as absent and
        // re-requested, not used to derive bad sub-keys.
        let bytes = AppStateSyncKeyWire {
            key_data: vec![0u8; 16],
            fingerprint: vec![1, 2, 3],
            timestamp: 1,
        }
        .encode_to_vec();
        assert!(decode_app_state_sync_key(&bytes).is_err());
    }

    #[test]
    fn hash_state_roundtrips() {
        let mut index_value_map = HashMap::new();
        index_value_map.insert("idx-a".to_string(), vec![1, 2, 3]);
        index_value_map.insert("idx-b".to_string(), vec![]);
        let mut hash = [0u8; 128];
        hash[0] = 0xFF;
        hash[127] = 0x11;
        let state = HashState {
            version: 42,
            hash,
            index_value_map: index_value_map.clone(),
            mac_mismatch_fatal: true,
        };
        let decoded = decode_hash_state(&encode_hash_state(&state)).unwrap();
        assert_eq!(decoded.version, 42);
        assert_eq!(decoded.hash, hash);
        assert_eq!(decoded.index_value_map, index_value_map);
        assert!(
            decoded.mac_mismatch_fatal,
            "a latched collection must stay latched across restarts, or the \
             divergence is re-detected on every patch forever"
        );
    }

    #[test]
    fn hash_state_default_roundtrips() {
        let state = HashState::default();
        let decoded = decode_hash_state(&encode_hash_state(&state)).unwrap();
        assert_eq!(decoded.version, 0);
        assert_eq!(decoded.hash, [0u8; 128]);
        assert!(decoded.index_value_map.is_empty());
        assert!(!decoded.mac_mismatch_fatal);
    }

    /// Rows written before `mac_mismatch_fatal` existed omit field 4 entirely;
    /// proto3 decodes that to `false`, which is the healthy state.
    #[test]
    fn hash_state_row_without_the_latch_field_decodes_as_healthy() {
        let bytes = HashStateWire {
            version: 9,
            hash: vec![0u8; 128],
            index_value_map: Default::default(),
            mac_mismatch_fatal: false,
        }
        .encode_to_vec();
        let decoded = decode_hash_state(&bytes).expect("an old row must still decode");
        assert_eq!(decoded.version, 9);
        assert!(!decoded.mac_mismatch_fatal);
    }

    #[test]
    fn hash_state_rejects_wrong_hash_len() {
        // A wire blob whose hash is not 128 bytes must error, not silently truncate.
        let bytes = HashStateWire {
            version: 1,
            hash: vec![0u8; 64],
            index_value_map: Default::default(),
            mac_mismatch_fatal: false,
        }
        .encode_to_vec();
        assert!(decode_hash_state(&bytes).is_err());
    }
}